# frozen_string_literal: true RSpec.describe Admin::ThemesController do fab!(:admin) fab!(:moderator) fab!(:user) let! :repo do setup_git_repo("about.json" => { name: "discourse-branch-header" }.to_json) end let! :repo_url do MockGitImporter.register("https://github.com/discourse/discourse-brand-header.git", repo) end around(:each) { |group| MockGitImporter.with_mock { group.run } } describe "#generate_key_pair" do context "when logged in as an admin" do before { sign_in(admin) } it "can generate key pairs" do post "/admin/themes/generate_key_pair.json" expect(response.status).to eq(200) json = response.parsed_body expect(json["private_key"]).to eq(nil) expect(json["public_key"]).to include("ssh-rsa ") expect(Discourse.redis.get("ssh_key_#{json["public_key"]}")).not_to eq(nil) end end shared_examples "key pair generation not allowed" do it "prevents key pair generation with a 404 response" do post "/admin/themes/generate_key_pair.json" expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "key pair generation not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "key pair generation not allowed" end end describe "#upload_asset" do let(:file) { file_from_fixtures("fake.woff2", "woff2") } let(:filename) { File.basename(file) } let(:upload) { Rack::Test::UploadedFile.new(file) } context "when logged in as an admin" do before { sign_in(admin) } it "can create a theme upload" do post "/admin/themes/upload_asset.json", params: { file: upload } expect(response.status).to eq(201) upload = Upload.find_by(original_filename: filename) expect(upload.id).not_to be_nil expect(response.parsed_body["upload_id"]).to eq(upload.id) end context "when trying to upload an existing file" do let(:uploaded_file) { Upload.find_by(original_filename: filename) } let(:response_json) { response.parsed_body } it "reuses the original upload" do post "/admin/themes/upload_asset.json", params: { file: upload } expect(response.status).to eq(201) expect(response_json["upload_id"]).to eq(uploaded_file.id) end end end shared_examples "theme asset upload not allowed" do it "prevents theme asset upload with a 404 response" do expect do post "/admin/themes/upload_asset.json", params: { file: upload } end.not_to change { Upload.count } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "theme asset upload not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "theme asset upload not allowed" end end describe "#export" do context "when logged in as an admin" do before { sign_in(admin) } it "exports correctly" do theme = Fabricate(:theme, name: "Awesome Theme") theme.set_field(target: :common, name: :scss, value: ".body{color: black;}") theme.set_field(target: :desktop, name: :after_header, value: "test") theme.set_field( target: :extra_js, name: "discourse/controller/blah", value: 'console.log("test");', ) theme.save! get "/admin/customize/themes/#{theme.id}/export" expect(response.status).to eq(200) # Save the output in a temp file (automatically cleaned up) file = Tempfile.new("archive.zip") file.write(response.body) file.rewind uploaded_file = Rack::Test::UploadedFile.new(file.path, "application/zip") # Now import it again expect do post "/admin/themes/import.json", params: { theme: uploaded_file } expect(response.status).to eq(201) end.to change { Theme.count }.by(1) json = response.parsed_body expect(json["theme"]["name"]).to eq("Awesome Theme") expect(json["theme"]["theme_fields"].length).to eq(3) end end shared_examples "theme export not allowed" do it "prevents theme export with a 404 response" do theme = Fabricate(:theme, name: "Awesome Theme") get "/admin/customize/themes/#{theme.id}/export" expect(response.status).to eq(404) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "theme export not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "theme export not allowed" end end describe "#import" do let(:theme_json_file) do Rack::Test::UploadedFile.new( file_from_fixtures("sam-s-simple-theme.dcstyle.json", "json"), "application/json", ) end let(:theme_archive) do Rack::Test::UploadedFile.new( file_from_fixtures("discourse-test-theme.zip", "themes"), "application/zip", ) end let(:image) { file_from_fixtures("logo.png") } context "when logged in as an admin" do before { sign_in(admin) } context "when theme allowlist mode is enabled" do before do global_setting :allowed_theme_repos, "https://github.com/discourse/discourse-brand-header.git" end it "allows allowlisted imports" do expect(Theme.allowed_remote_theme_ids.length).to eq(0) post "/admin/themes/import.json", params: { remote: " https://github.com/discourse/discourse-brand-header.git ", } expect(Theme.allowed_remote_theme_ids.length).to eq(1) expect(response.status).to eq(201) end it "prevents adding disallowed themes" do RemoteTheme.stubs(:import_theme) remote = " https://bad.com/discourse/discourse-brand-header.git " post "/admin/themes/import.json", params: { remote: remote } expect(response.status).to eq(403) expect(response.parsed_body["errors"]).to include( I18n.t("themes.import_error.not_allowed_theme", { repo: remote.strip }), ) end it "bans json file import" do post "/admin/themes/import.json", params: { theme: theme_json_file } expect(response.status).to eq(403) end end it "can import a theme from Git" do RemoteTheme.stubs(:import_theme) post "/admin/themes/import.json", params: { remote: " https://github.com/discourse/discourse-brand-header.git ", } expect(response.status).to eq(201) end it "responds with suitable error message when a migration fails" do repo_path = setup_git_repo( "about.json" => { name: "test theme" }.to_json, "settings.yaml" => "boolean_setting: true", "migrations/settings/0001-some-migration.js" => <<~JS, export default function migrate(settings) { settings.set("unknown_setting", "dsad"); return settings; } JS ) repo_url = MockGitImporter.register("https://example.com/initial_repo.git", repo_path) post "/admin/themes/import.json", params: { remote: repo_url } expect(response.status).to eq(422) expect(response.parsed_body["errors"]).to contain_exactly( I18n.t( "themes.import_error.migrations.unknown_setting_returned_by_migration", name: "0001-some-migration", setting_name: "unknown_setting", ), ) end it "fails to import with a failing status" do post "/admin/themes/import.json", params: { remote: "non-existent" } expect(response.status).to eq(422) end it "fails to import with a failing status" do post "/admin/themes/import.json", params: { remote: "https://#{"a" * 10_000}.com" } expect(response.status).to eq(422) end it "can lookup a private key by public key" do Discourse.redis.setex("ssh_key_abcdef", 1.hour, "rsa private key") post "/admin/themes/import.json", params: { remote: " #{repo_url} ", public_key: "abcdef", } expect(RemoteTheme.last.private_key).to eq("rsa private key") expect(response.status).to eq(201) end it "imports a theme" do post "/admin/themes/import.json", params: { theme: theme_json_file } expect(response.status).to eq(201) json = response.parsed_body expect(json["theme"]["name"]).to eq("Sam's Simple Theme") expect(json["theme"]["theme_fields"].length).to eq(2) expect(json["theme"]["auto_update"]).to eq(false) expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) end it "can fail if theme is not accessible" do post "/admin/themes/import.json", params: { remote: "git@github.com:discourse/discourse-inexistent-theme.git", } expect(response.status).to eq(422) expect(response.parsed_body["errors"]).to contain_exactly(I18n.t("themes.import_error.git")) end it "can force install theme" do post "/admin/themes/import.json", params: { remote: "git@github.com:discourse/discourse-inexistent-theme.git", force: true, } expect(response.status).to eq(201) expect(response.parsed_body["theme"]["name"]).to eq("discourse-inexistent-theme") end it "fails to import with an error if uploads are not allowed" do SiteSetting.theme_authorized_extensions = "nothing" expect do post "/admin/themes/import.json", params: { theme: theme_archive } end.not_to change { Theme.count } expect(response.status).to eq(422) end it "imports a theme from an archive" do _existing_theme = Fabricate(:theme, name: "Header Icons") expect do post "/admin/themes/import.json", params: { theme: theme_archive } end.to change { Theme.count }.by(1) expect(response.status).to eq(201) json = response.parsed_body expect(json["theme"]["name"]).to eq("Header Icons") expect(json["theme"]["theme_fields"].length).to eq(6) expect(json["theme"]["auto_update"]).to eq(false) expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) end it "updates an existing theme from an archive by id" do # Used by theme CLI _existing_theme = Fabricate(:theme, name: "Header Icons") other_existing_theme = Fabricate(:theme, name: "Some other name") messages = MessageBus.track_publish("/file-change") do expect do post "/admin/themes/import.json", params: { bundle: theme_archive, theme_id: other_existing_theme.id, } expect(response.status).to eq(201) end.not_to change { Theme.count } end json = response.parsed_body # Ensure only one refresh message is sent. # More than 1 is wasteful, and can trigger unusual race conditions in the client # If this test fails, it probably means `theme.save` is being called twice - check any 'autosave' relations expect(messages.count).to eq(1) expect(json["theme"]["name"]).to eq("Some other name") expect(json["theme"]["id"]).to eq(other_existing_theme.id) expect(json["theme"]["theme_fields"].length).to eq(6) expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) end it "does not run migrations when importing a theme from an archive and `skip_settings_migrations` params is present" do other_existing_theme = Fabricate(:theme, name: "Some other name") post "/admin/themes/import.json", params: { bundle: theme_archive, theme_id: other_existing_theme.id, skip_migrations: true, } expect(response.status).to eq(201) expect(other_existing_theme.theme_settings_migrations.exists?).to eq(false) end it "creates a new theme when id specified as nil" do # Used by theme CLI existing_theme = Fabricate(:theme, name: "Header Icons") expect do post "/admin/themes/import.json", params: { bundle: theme_archive, theme_id: nil } end.to change { Theme.count }.by(1) expect(response.status).to eq(201) json = response.parsed_body expect(json["theme"]["name"]).to eq("Header Icons") expect(json["theme"]["id"]).not_to eq(existing_theme.id) expect(json["theme"]["theme_fields"].length).to eq(6) expect(json["theme"]["auto_update"]).to eq(false) expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) end end shared_examples "theme import not allowed" do it "prevents theme import with a 404 response" do post "/admin/themes/import.json", params: { theme: theme_json_file } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "theme import not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "theme import not allowed" end end describe "#index" do context "when logged in as an admin" do before { sign_in(admin) } it "correctly returns themes" do ColorScheme.destroy_all Theme.destroy_all theme = Fabricate(:theme) theme.set_field(target: :common, name: :scss, value: ".body{color: black;}") theme.set_field(target: :desktop, name: :after_header, value: "test") theme.set_field( target: :migrations, name: "0001-some-migration", value: "export default function migrate(settings) { return settings; }", ) theme.remote_theme = RemoteTheme.new( remote_url: "awesome.git", remote_version: "7", local_version: "8", remote_updated_at: Time.zone.now, ) theme.save! # this will get serialized as well ColorScheme.create_from_base(name: "test", colors: []) get "/admin/themes.json" expect(response.status).to eq(200) json = response.parsed_body expect(json["extras"]["color_schemes"].length).to eq(1) theme_json = json["themes"].find { |t| t["id"] == theme.id } expect(theme_json["theme_fields"].length).to eq(3) expect( theme_json["theme_fields"].find { |theme_field| theme_field["target"] == "migrations" }[ "migrated" ], ).to eq(false) expect(theme_json["remote_theme"]["remote_version"]).to eq("7") end end shared_examples "themes inaccessible" do it "denies access with a 404 response" do get "/admin/themes.json" expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "themes inaccessible" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "themes inaccessible" end end describe "#create" do context "when logged in as an admin" do before { sign_in(admin) } it "creates a theme" do post "/admin/themes.json", params: { theme: { name: "my test name", theme_fields: [name: "scss", target: "common", value: "body{color: red;}"], }, } expect(response.status).to eq(201) json = response.parsed_body expect(json["theme"]["theme_fields"].length).to eq(1) expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) end end shared_examples "theme creation not allowed" do it "prevents creation with a 404 response" do expect do post "/admin/themes.json", params: { theme: { name: "my test name", theme_fields: [name: "scss", target: "common", value: "body{color: red;}"], }, } end.not_to change { Theme.count } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "theme creation not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "theme creation not allowed" end end describe "#update" do let!(:theme) { Fabricate(:theme) } context "when logged in as an admin" do before { sign_in(admin) } it "returns the right response when an invalid id is given" do put "/admin/themes/99999.json" expect(response.status).to eq(400) end it "can change default theme" do SiteSetting.default_theme_id = -1 put "/admin/themes/#{theme.id}.json", params: { id: theme.id, theme: { default: true } } expect(response.status).to eq(200) expect(SiteSetting.default_theme_id).to eq(theme.id) end it "can unset default theme" do SiteSetting.default_theme_id = theme.id put "/admin/themes/#{theme.id}.json", params: { theme: { default: false } } expect(response.status).to eq(200) expect(SiteSetting.default_theme_id).to eq(-1) end context "when theme allowlist mode is enabled" do before do global_setting :allowed_theme_repos, " https://magic.com/repo.git, https://x.com/git" end it "unconditionally bans theme_fields from updating" do r = RemoteTheme.create!(remote_url: "https://magic.com/repo.git") theme.update!(remote_theme_id: r.id) put "/admin/themes/#{theme.id}.json", params: { theme: { name: "my test name", theme_fields: [ { name: "scss", target: "common", value: "" }, { name: "scss", target: "desktop", value: "body{color: blue;}" }, ], }, } expect(response.status).to eq(403) end end it "updates a theme" do theme.set_field(target: :common, name: :scss, value: ".body{color: black;}") theme.save child_theme = Fabricate(:theme, component: true) upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for( Discourse.system_user.id, ) put "/admin/themes/#{theme.id}.json", params: { theme: { child_theme_ids: [child_theme.id], name: "my test name", theme_fields: [ { name: "scss", target: "common", value: "" }, { name: "scss", target: "desktop", value: "body{color: blue;}" }, { name: "bob", target: "common", value: "", type_id: 2, upload_id: upload.id }, ], }, } expect(response.status).to eq(200) json = response.parsed_body expect(json["theme"]["theme_fields"].length).to eq(2) expect(json["theme"]["child_themes"].length).to eq(1) expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) end it "prevents theme update when using ember css selectors" do child_theme = Fabricate(:theme, component: true) put "/admin/themes/#{theme.id}.json", params: { theme: { child_theme_ids: [child_theme.id], name: "my test name", theme_fields: [ { name: "scss", target: "common", value: "" }, { name: "scss", target: "desktop", value: ".ember-view{color: blue;}" }, ], }, } expect(response.status).to eq(200) json = response.parsed_body fields = json["theme"]["theme_fields"].sort { |a, b| a["value"] <=> b["value"] } expect(fields[0]["error"]).to eq(I18n.t("themes.ember_selector_error")) put "/admin/themes/#{theme.id}.json", params: { theme: { child_theme_ids: [child_theme.id], name: "my test name", theme_fields: [ { name: "scss", target: "common", value: "" }, { name: "scss", target: "desktop", value: "#ember392{color: blue;}" }, ], }, } expect(response.status).to eq(200) json = response.parsed_body fields = json["theme"]["theme_fields"].sort { |a, b| a["value"] <=> b["value"] } expect(fields[0]["error"]).to eq(I18n.t("themes.ember_selector_error")) end it "blocks remote theme fields from being locally edited" do r = RemoteTheme.create!(remote_url: "https://magic.com/repo.git") theme.update!(remote_theme_id: r.id) put "/admin/themes/#{theme.id}.json", params: { theme: { theme_fields: [ { name: "scss", target: "common", value: "" }, { name: "header", target: "common", value: "filename.jpg", upload_id: 4 }, ], }, } expect(response.status).to eq(403) end it "creates new theme fields" do expect(theme.theme_fields.count).to eq(0) put "/admin/themes/#{theme.id}.json", params: { theme: { theme_fields: [{ name: "scss", target: "common", value: "test" }], }, } expect(response.status).to eq(200) theme.reload expect(theme.theme_fields.count).to eq(1) theme_field = theme.theme_fields.first expect(theme_field.name).to eq("scss") expect(theme_field.target_id).to eq(Theme.targets[:common]) expect(theme_field.value).to eq("test") end it "doesn't create theme fields when they don't pass validation" do expect(theme.theme_fields.count).to eq(0) put "/admin/themes/#{theme.id}.json", params: { theme: { theme_fields: [ { name: "scss", target: "common", value: "Na " * 1024**2 + "Batman!" }, ], }, } expect(response.status).to eq(422) json = JSON.parse(response.body) expect(json["errors"].first).to include("Value is too long") end it "allows zip-imported theme fields to be locally edited" do r = RemoteTheme.create!(remote_url: "") theme.update!(remote_theme_id: r.id) put "/admin/themes/#{theme.id}.json", params: { theme: { theme_fields: [ { name: "scss", target: "common", value: "" }, { name: "header", target: "common", value: "filename.jpg", upload_id: 4 }, ], }, } expect(response.status).to eq(200) end it "updates a child theme" do child_theme = Fabricate(:theme, component: true) put "/admin/themes/#{child_theme.id}.json", params: { theme: { parent_theme_ids: [theme.id], }, } expect(child_theme.parent_themes).to eq([theme]) end it "can update translations" do theme.set_field( target: :translations, name: :en, value: { en: { somegroup: { somestring: "defaultstring" } } }.deep_stringify_keys.to_yaml, ) theme.save! put "/admin/themes/#{theme.id}.json", params: { theme: { translations: { "somegroup.somestring" => "overriddenstring", }, }, } # Response correct expect(response.status).to eq(200) json = response.parsed_body expect(json["theme"]["translations"][0]["value"]).to eq("overriddenstring") # Database correct theme.reload expect(theme.theme_translation_overrides.count).to eq(1) expect(theme.theme_translation_overrides.first.translation_key).to eq( "somegroup.somestring", ) # Set back to default put "/admin/themes/#{theme.id}.json", params: { theme: { translations: { "somegroup.somestring" => "defaultstring", }, }, } # Response correct expect(response.status).to eq(200) json = response.parsed_body expect(json["theme"]["translations"][0]["value"]).to eq("defaultstring") # Database correct theme.reload expect(theme.theme_translation_overrides.count).to eq(0) end it "checking for updates saves the remote_theme record" do theme.remote_theme = RemoteTheme.create!( remote_url: "http://discourse.org", remote_version: "a", local_version: "a", commits_behind: 0, ) theme.save! ThemeStore::GitImporter.any_instance.stubs(:import!) ThemeStore::GitImporter.any_instance.stubs(:commits_since).returns(["b", 1]) put "/admin/themes/#{theme.id}.json", params: { theme: { remote_check: true } } theme.reload expect(theme.remote_theme.remote_version).to eq("b") expect(theme.remote_theme.commits_behind).to eq(1) end it "can disable component" do child = Fabricate(:theme, component: true) put "/admin/themes/#{child.id}.json", params: { theme: { enabled: false } } expect(response.status).to eq(200) json = response.parsed_body expect(json["theme"]["enabled"]).to eq(false) expect( UserHistory.where( context: child.id.to_s, action: UserHistory.actions[:disable_theme_component], ).size, ).to eq(1) expect(json["theme"]["disabled_by"]["id"]).to eq(admin.id) end it "enabling/disabling a component creates the correct staff action log" do child = Fabricate(:theme, component: true) UserHistory.destroy_all put "/admin/themes/#{child.id}.json", params: { theme: { enabled: false } } expect(response.status).to eq(200) expect( UserHistory.where( context: child.id.to_s, action: UserHistory.actions[:disable_theme_component], ).size, ).to eq(1) expect( UserHistory.where( context: child.id.to_s, action: UserHistory.actions[:enable_theme_component], ).size, ).to eq(0) put "/admin/themes/#{child.id}.json", params: { theme: { enabled: true } } expect(response.status).to eq(200) json = response.parsed_body expect( UserHistory.where( context: child.id.to_s, action: UserHistory.actions[:disable_theme_component], ).size, ).to eq(1) expect( UserHistory.where( context: child.id.to_s, action: UserHistory.actions[:enable_theme_component], ).size, ).to eq(1) expect(json["theme"]["disabled_by"]).to eq(nil) expect(json["theme"]["enabled"]).to eq(true) end it "handles import errors on update" do theme.create_remote_theme!(remote_url: "https://example.com/repository") theme.save! # RemoteTheme is extensively tested, and setting up the test scaffold is a large overhead # So use a stub here to test the controller RemoteTheme .any_instance .stubs(:update_from_remote) .raises(RemoteTheme::ImportError.new("error message")) put "/admin/themes/#{theme.id}.json", params: { theme: { remote_update: true } } expect(response.status).to eq(422) expect(response.parsed_body["errors"].first).to eq("error message") end it "returns the right error message" do theme.update!(component: true) put "/admin/themes/#{theme.id}.json", params: { theme: { default: true } } expect(response.status).to eq(400) expect(response.parsed_body["errors"].first).to include( I18n.t("themes.errors.component_no_default"), ) end it "prevents converting the default theme to a component" do SiteSetting.default_theme_id = theme.id put "/admin/themes/#{theme.id}.json", params: { theme: { component: true } } # should this error message be localized? InvalidParameters :component expect(response.status).to eq(400) expect(response.parsed_body["errors"].first).to include("component") end end shared_examples "theme update not allowed" do it "prevents updates with a 404 response" do SiteSetting.default_theme_id = -1 put "/admin/themes/#{theme.id}.json", params: { id: theme.id, theme: { default: true } } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) expect(SiteSetting.default_theme_id).not_to eq(theme.id) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "theme update not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "theme update not allowed" end end describe "#destroy" do let!(:theme) { Fabricate(:theme) } context "when logged in as an admin" do before { sign_in(admin) } it "returns the right response when an invalid id is given" do delete "/admin/themes/9999.json" expect(response.status).to eq(400) end it "deletes the field's javascript cache" do theme.set_field( target: :common, name: :header, value: '', ) theme.save! javascript_cache = theme .theme_fields .find_by(target_id: Theme.targets[:common], name: :header) .javascript_cache expect(javascript_cache).to_not eq(nil) delete "/admin/themes/#{theme.id}.json" expect(response.status).to eq(204) expect { theme.reload }.to raise_error(ActiveRecord::RecordNotFound) expect { javascript_cache.reload }.to raise_error(ActiveRecord::RecordNotFound) end end shared_examples "theme deletion not allowed" do it "prevent deletion with a 404 response" do delete "/admin/themes/#{theme.id}.json" expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) expect(theme.reload).to be_present end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "theme deletion not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "theme deletion not allowed" end end describe "#preview" do context "when logged in as an admin" do before { sign_in(admin) } it "should return the right response when an invalid id is given" do get "/admin/themes/9999/preview.json" expect(response.status).to eq(400) end end shared_examples "theme previews inaccessible" do it "denies access with a 404 response" do theme = Fabricate(:theme) get "/admin/themes/#{theme.id}/preview.json" expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "theme previews inaccessible" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "theme previews inaccessible" end end describe "#update_single_setting" do fab!(:theme) before do theme.set_field(target: :settings, name: :yaml, value: "bg: red") theme.save! end context "when logged in as an admin" do before { sign_in(admin) } it "should update a theme setting" do put "/admin/themes/#{theme.id}/setting.json", params: { name: "bg", value: "green" } expect(response.status).to eq(200) expect(response.parsed_body["bg"]).to eq("green") theme.reload expect(theme.cached_settings[:bg]).to eq("green") user_history = UserHistory.last expect(user_history.action).to eq(UserHistory.actions[:change_theme_setting]) end it "should return the right error when value used to update a theme setting of `objects` typed is invalid" do field = theme.set_field( target: :settings, name: "yaml", value: File.read("#{Rails.root}/spec/fixtures/theme_settings/objects_settings.yaml"), ) theme.save! put "/admin/themes/#{theme.id}/setting.json", params: { name: "objects_setting", value: [ { name: "new_section", links: [{ name: "a" * 21, url: "https://some.url.com" }] }, ].to_json, } expect(response.status).to eq(422) expect(response.parsed_body["errors"]).to eq( ["The property at JSON Pointer '/0/links/0/name' must be at most 20 characters long."], ) end it "should be able to update a theme setting of `objects` typed" do field = theme.set_field( target: :settings, name: "yaml", value: File.read("#{Rails.root}/spec/fixtures/theme_settings/objects_settings.yaml"), ) theme.save! put "/admin/themes/#{theme.id}/setting.json", params: { name: "objects_setting", value: [ { name: "new_section", links: [{ name: "new link", url: "https://some.url.com" }] }, ].to_json, } expect(response.status).to eq(200) expect(theme.settings[:objects_setting].value).to eq( [ { "name" => "new_section", "links" => [{ "name" => "new link", "url" => "https://some.url.com" }], }, ], ) end it "should clear a theme setting" do put "/admin/themes/#{theme.id}/setting.json", params: { name: "bg" } theme.reload expect(response.status).to eq(200) expect(theme.cached_settings[:bg]).to eq("") end end shared_examples "theme update not allowed" do it "prevents updates with a 404 response" do put "/admin/themes/#{theme.id}/setting.json", params: { name: "bg", value: "green" } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) theme.reload expect(theme.cached_settings[:bg]).to eq("red") end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "theme update not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "theme update not allowed" end end describe "#update_translations" do fab!(:theme) before do theme.set_field( target: :translations, name: :en, value: { en: { group: { hello: "Hello there!" } } }.deep_stringify_keys.to_yaml, ) theme.set_field( target: :translations, name: :fr, value: { fr: { group: { hello: "Bonjour Mes Amis!" } } }.deep_stringify_keys.to_yaml, ) theme.save! end context "when logged in as an admin" do before { sign_in(admin) } it "should update a theme translation" do put "/admin/themes/#{theme.id}.json", params: { theme: { translations: { "group.hello" => "Hello there! updated", }, }, } expect(response.status).to eq(200) theme.reload.translations.map { |t| expect(t.value).to eq("Hello there! updated") } end it "should update a theme translation with locale" do put "/admin/themes/#{theme.id}.json", params: { theme: { translations: { "group.hello" => "Hello there! updated", }, locale: "en", }, } expect(response.status).to eq(200) theme.reload.translations.map { |t| expect(t.value).to eq("Hello there! updated") } end it "should fail update a theme translation when locale is wrong" do put "/admin/themes/#{theme.id}.json", params: { theme: { translations: { "group.hello" => "Hello there! updated", }, locale: "foo", }, } expect(response.status).to eq(400) expect(response.parsed_body["errors"]).to include( I18n.t("invalid_params", message: :locale), ) end it "should update other locale and do not change current one" do put "/admin/themes/#{theme.id}.json", params: { theme: { translations: { "group.hello" => "Bonjour Mes Amis! updated", }, locale: "fr", }, } expect(response.status).to eq(200) theme.reload.translations.map { |t| expect(t.value).to eq("Hello there!") } get "/admin/themes/#{theme.id}/translations/fr.json" translations = response.parsed_body["translations"] expect(translations.first["value"]).to eq("Bonjour Mes Amis! updated") end end shared_examples "theme update not allowed" do it "prevents updates with a 404 response" do put "/admin/themes/#{theme.id}.json", params: { theme: { translations: { "group.hello" => "Bonjour Mes Amis! updated", }, locale: "fr", }, } expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "theme update not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "theme update not allowed" end end describe "#get_translations" do fab!(:theme) before do theme.set_field( target: :translations, name: :en, value: { en: { group: { hello: "Hello there!" } } }.deep_stringify_keys.to_yaml, ) theme.save! end context "when logged in as an admin" do before { sign_in(admin) } it "get translations from theme" do get "/admin/themes/#{theme.id}/translations/en.json" translations = response.parsed_body["translations"] expect(translations.first["value"]).to eq("Hello there!") end it "fail if get translations from theme with wrong locale" do get "/admin/themes/#{theme.id}/translations/foo.json" expect(response.status).to eq(400) expect(response.parsed_body["errors"]).to include( I18n.t("invalid_params", message: :locale), ) end end shared_examples "get theme translations not allowed" do it "prevents updates with a 404 response" do get "/admin/themes/#{theme.id}/translations/en.json" expect(response.status).to eq(404) expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) end end context "when logged in as a moderator" do before { sign_in(moderator) } include_examples "get theme translations not allowed" end context "when logged in as a non-staff user" do before { sign_in(user) } include_examples "get theme translations not allowed" end end describe "#bulk_destroy" do fab!(:theme) { Fabricate(:theme, name: "Awesome Theme") } fab!(:theme_2) { Fabricate(:theme, name: "Another awesome Theme") } let(:theme_ids) { [theme.id, theme_2.id] } before { sign_in(admin) } it "destroys all selected the themes" do expect do delete "/admin/themes/bulk_destroy.json", params: { theme_ids: theme_ids } end.to change { Theme.count }.by(-2) end it "logs the theme destroy action for each theme" do StaffActionLogger.any_instance.expects(:log_theme_destroy).twice delete "/admin/themes/bulk_destroy.json", params: { theme_ids: theme_ids } end end describe "#objects_setting_metadata" do fab!(:theme) let(:theme_setting) do yaml = File.read("#{Rails.root}/spec/fixtures/theme_settings/objects_settings.yaml") field = theme.set_field(target: :settings, name: "yaml", value: yaml) theme.save! theme.settings end it "returns 404 if user is not an admin" do get "/admin/themes/#{theme.id}/objects_setting_metadata/objects_with_categories.json" expect(response.status).to eq(404) sign_in(user) get "/admin/themes/#{theme.id}/objects_setting_metadata/objects_with_categories.json" expect(response.status).to eq(404) sign_in(moderator) get "/admin/themes/#{theme.id}/objects_setting_metadata/objects_with_categories.json" expect(response.status).to eq(404) end context "when user is an admin" do before { sign_in(admin) } it "returns 400 if the `id` param is not the id of a valid theme" do get "/admin/themes/some_invalid_id/objects_setting_metadata/objects_with_categories.json" expect(response.status).to eq(400) end it "returns 400 if the `setting_name` param does not match a valid setting" do get "/admin/themes/#{theme.id}/objects_setting_metadata/some_invalid_setting_name.json" expect(response.status).to eq(400) end it "returns 200 with the right `property_descriptions` attributes" do theme.set_field( target: :translations, name: "en", value: File.read("#{Rails.root}/spec/fixtures/theme_locales/objects_settings/en.yaml"), ) theme.save! theme_setting get "/admin/themes/#{theme.id}/objects_setting_metadata/objects_setting.json" expect(response.status).to eq(200) expect(response.parsed_body["property_descriptions"]).to eq( { "links.name.description" => "Name of the link", "links.name.label" => "Name", "links.url.description" => "URL of the link", "links.url.label" => "URL", "name.description" => "Section Name", "name.label" => "Name", }, ) end it "returns 200 with the right `categories` attribute for a theme setting with categories propertoes" do category_1 = Fabricate(:category) category_2 = Fabricate(:category) category_3 = Fabricate(:category) theme_setting[:objects_with_categories].value = [ { "category_ids" => [category_1.id, category_2.id], "child_categories" => [{ "category_ids" => [category_3.id] }], }, ] get "/admin/themes/#{theme.id}/objects_setting_metadata/objects_with_categories.json" expect(response.status).to eq(200) categories = response.parsed_body["categories"] expect(categories.keys.map(&:to_i)).to contain_exactly( category_1.id, category_2.id, category_3.id, ) expect(categories[category_1.id.to_s]["name"]).to eq(category_1.name) expect(categories[category_2.id.to_s]["name"]).to eq(category_2.name) expect(categories[category_3.id.to_s]["name"]).to eq(category_3.name) end end end describe "#schema" do fab!(:theme) fab!(:theme_component) { Fabricate(:theme, component: true) } before { sign_in(admin) } it "returns 200 when customizing a theme's setting of objects type" do get "/admin/customize/themes/#{theme.id}/schema/some_setting_name" expect(response.status).to eq(200) end it "returns 200 when customizing a theme component's setting of objects type" do get "/admin/customize/components/#{theme_component.id}/schema/some_setting_name" expect(response.status).to eq(200) end end end