# frozen_string_literal: true
require 'rails_helper'
describe Admin::ThemesController do
fab!(:admin) { Fabricate(:admin) }
it "is a subclass of AdminController" do
expect(Admin::UsersController < Admin::AdminController).to eq(true)
end
before do
sign_in(admin)
end
describe '#generate_key_pair' do
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 include("RSA PRIVATE KEY")
expect(json["public_key"]).to include("ssh-rsa ")
end
end
describe '#upload_asset' do
let(:file) { file_from_fixtures("fake.woff2", "woff2") }
let(:filename) { File.basename(file) }
let(:upload) do
Rack::Test::UploadedFile.new(file)
end
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
describe '#export' do
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
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) do
file_from_fixtures("logo.png")
end
context 'when theme allowlist mode is enabled' do
before do
GlobalSetting.reset_allowed_theme_ids!
global_setting :allowed_theme_repos, "https://github.com/discourse/discourse-brand-header"
end
after do
GlobalSetting.reset_allowed_theme_ids!
end
it "allows allowlisted imports" do
RemoteTheme.stubs(:import_theme)
post "/admin/themes/import.json", params: {
remote: ' https://github.com/discourse/discourse-brand-header '
}
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 '
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 '
}
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(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1)
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(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(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1)
end
end
describe '#index' do
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
describe '#create' do
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
describe '#update' do
let!(:theme) { Fabricate(:theme) }
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
GlobalSetting.reset_allowed_theme_ids!
global_setting :allowed_theme_repos, " https://magic.com/repo.git, https://x.com/git"
end
after do
GlobalSetting.reset_allowed_theme_ids!
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
fields = json["theme"]["theme_fields"].sort { |a, b| a["value"] <=> b["value"] }
expect(fields[0]["value"]).to eq('')
expect(fields[0]["upload_id"]).to eq(upload.id)
expect(fields[1]["value"]).to eq('body{color: blue;}')
expect(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" => "overridenstring"
}
}
}
# Response correct
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["theme"]["translations"][0]["value"]).to eq("overridenstring")
# 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
describe '#destroy' do
let!(:theme) { Fabricate(:theme) }
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
describe '#preview' do
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
describe '#update_single_setting' do
let(:theme) { Fabricate(:theme) }
before do
theme.set_field(target: :settings, name: :yaml, value: "bg: red")
theme.save!
end
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.included_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.included_settings[:bg]).to eq("")
end
end
end