# frozen_string_literal: true RSpec.describe Admin::ThemesController do fab!(:admin) { Fabricate(:admin) } fab!(:moderator) { Fabricate(:moderator) } fab!(:user) { Fabricate(: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 } before do post "/admin/themes/upload_asset.json", params: { file: upload } expect(response.status).to eq(201) end it "reuses the original upload" do 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 "fails to import with a failing status" do post "/admin/themes/import.json", params: { remote: "non-existant" } 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") ThemeStore::GitImporter.any_instance.stubs(:import!) RemoteTheme.stubs(:extract_theme_info).returns( "name" => "discourse-brand-header", "component" => true, ) RemoteTheme.any_instance.stubs(:update_from_remote) post "/admin/themes/import.json", params: { remote: " https://github.com/discourse/discourse-brand-header.git ", 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.to change { Theme.count }.by (0) 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(5) 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 name" do # Old theme CLI method, remove Jan 2020 _existing_theme = Fabricate(:theme, name: "Header Icons") expect do post "/admin/themes/import.json", params: { bundle: theme_archive } end.to change { Theme.count }.by (0) 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(5) 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 do expect do post "/admin/themes/import.json", params: { bundle: theme_archive, theme_id: other_existing_theme.id, } end.to change { Theme.count }.by (0) end expect(response.status).to eq(201) 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 file_change_messages = messages.filter { |m| m[:channel] == "/file-change" } expect(file_change_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(5) expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1) 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(5) 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.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(2) 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 "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 let(:theme) { Fabricate(: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 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 end