discourse/spec/requests/admin/themes_controller_spec.rb
discourse-patch-triage[bot] e0b78cf08b
FIX: Theme source updates are not atomic on import or migration failure (#39969)
## Summary

This patch ensures that theme source updates and settings migrations are
atomic by wrapping the update process in a database transaction. If an
error occurs during the source fetch or settings migration, the
transaction is rolled back and the theme object is reloaded to its
original state, preventing inconsistent metadata.

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/909
- Original commit: 

---

🤖 Auto-generated from the patch diff via Patch Triage. Review carefully
before merging.

Co-authored-by: discourse-patch-triage[bot] <272280883+discourse-patch-triage[bot]@users.noreply.github.com>
2026-05-13 12:14:00 +01:00

1984 lines
63 KiB
Ruby

# 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: "<b>test</b>")
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(4)
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_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
end
it "can import a theme from Git" do
RemoteTheme.stubs(:import_theme).returns(Fabricate(: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 "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(7)
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(7)
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(7)
expect(json["theme"]["auto_update"]).to eq(false)
expect(UserHistory.where(action: UserHistory.actions[:change_theme]).count).to eq(1)
end
context "with registered user_guardian_can_create_theme" do
after { DiscoursePluginRegistry.reset! }
it "doesn't allow importing a theme if the modifier returns false" do
plugin_instance = Plugin::Instance.new
can_create = true
plugin_instance.register_modifier(:user_guardian_can_create_theme) do |val, guardian|
expect(guardian.user).to eq(admin)
can_create
end
RemoteTheme.stubs(:import_theme).returns(Fabricate(:theme))
post "/admin/themes/import.json",
params: {
remote: "https://github.com/discourse/discourse-brand-header.git",
}
expect(response.status).to eq(201)
json = response.parsed_body
expect(json["theme"]).to be_present
can_create = false
expect do
post "/admin/themes/import.json",
params: {
remote: "https://github.com/discourse/discourse-brand-header-2.git",
}
end.not_to change { Theme.count }
expect(response.status).to eq(403)
end
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_archive }
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.not_system.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: "<b>test</b>")
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!
get "/admin/themes.json"
expect(response.status).to eq(200)
json = response.parsed_body
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
it "does not result in N+1 queries" do
# warmup
get "/admin/themes.json"
expect(response.status).to eq(200)
theme = Fabricate(:theme, color_scheme: Fabricate(:color_scheme))
Fabricate(
:theme_field,
target_id: Theme.targets[:translations],
theme: theme,
name: "en",
value:
"en:\n theme_metadata:\n description: \"A simple, beautiful theme that improves the out of the box experience for Discourse sites.\"\n topic_pinned: \"Pinned\"\n topic_hot: \"Hot\"\n user_replied: \"replied\"\n user_posted: \"posted\"\n user_updated: \"updated\"\n",
)
first_request_queries =
track_sql_queries do
get "/admin/themes.json"
expect(response.status).to eq(200)
end
theme_2 = Fabricate(:theme, color_scheme: Fabricate(:color_scheme))
Fabricate(
:theme_field,
target_id: Theme.targets[:translations],
theme: theme_2,
name: "en",
value:
"en:\n theme_metadata:\n description: \"A simple, beautiful theme that improves the out of the box experience for Discourse sites.\"\n topic_pinned: \"Pinned\"\n topic_hot: \"Hot\"\n user_replied: \"replied\"\n user_posted: \"posted\"\n user_updated: \"updated\"\n",
)
second_request_queries =
track_sql_queries do
get "/admin/themes.json"
expect(response.status).to eq(200)
end
expect(first_request_queries.count).to eq(second_request_queries.count)
end
it "includes color schemes in the `extras` object of the response body" do
base =
Fabricate(
:color_scheme,
color_scheme_colors: [
Fabricate(:color_scheme_color, name: "newcolor1", hex: "fafafa"),
Fabricate(:color_scheme_color, name: "newcolor2", hex: "afafaf"),
],
)
copy1 = Fabricate(:color_scheme, base_scheme_id: base.id)
copy2 = Fabricate(:color_scheme, base_scheme_id: base.id)
get "/admin/themes.json"
expect(response.status).to eq(200)
expect(response.parsed_body["extras"]["color_schemes"].map { |cs| cs["id"] }).to include(
base.id,
copy1.id,
copy2.id,
)
end
end
it "allows themes and components to be edited" do
sign_in(admin)
theme = Fabricate(:theme, name: "Awesome Theme")
component = Fabricate(:theme, name: "Awesome component", component: true)
get "/admin/customize/themes/#{theme.id}/common/scss/edit"
expect(response.status).to eq(200)
get "/admin/customize/components/#{component.id}/common/scss/edit"
expect(response.status).to eq(200)
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 and theme fields" 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
it "can set a theme to default" do
post "/admin/themes.json", params: { theme: { name: "my test name", default: "true" } }
expect(response.status).to eq(201)
json = response.parsed_body
expect(json["theme"]["default"]).to eq(true)
end
context "when creating a theme field with an invalid target" do
it "errors" do
post "/admin/themes.json",
params: {
theme: {
name: "my test name",
theme_fields: [name: "scss", target: "blah", value: "body{color: red;}"],
},
}
expect(response.status).to eq(400)
json = response.parsed_body
expect(json["errors"]).to include("Unknown target blah passed to set field")
end
end
context "when creating a theme field with an invalid type" do
it "errors" do
post "/admin/themes.json",
params: {
theme: {
name: "my test name",
theme_fields: [name: "blahblah", target: "common", value: "body{color: red;}"],
},
}
expect(response.status).to eq(400)
json = response.parsed_body
expect(json["errors"]).to include(
"No type could be guessed for field blahblah for target common",
)
end
end
context "with registered user_guardian_can_create_theme" do
after { DiscoursePluginRegistry.reset! }
it "doesn't allow theme creation if the modifier returns false" do
plugin_instance = Plugin::Instance.new
can_create = true
plugin_instance.register_modifier(:user_guardian_can_create_theme) do |val, guardian|
expect(guardian.user).to eq(admin)
can_create
end
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"]).to be_present
can_create = false
expect do
post "/admin/themes.json",
params: {
theme: {
name: "my test name 2",
theme_fields: [name: "scss", target: "common", value: "body{color: red;}"],
},
}
end.not_to change { Theme.count }
expect(response.status).to eq(403)
end
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
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 "prevents theme creation with 404 error" do
expect do
post "/admin/themes.json", params: { theme: { name: "my test name" } }
end.not_to change { Theme.count }
expect(response.status).to eq(404)
end
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 set system theme as default" do
theme.update_columns(id: -10)
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 "only allows to update certain fields for system themes" do
theme.update_columns(id: -10)
child_theme = Fabricate(:theme, component: true)
put "/admin/themes/#{theme.id}.json",
params: {
theme: {
child_theme_ids: [child_theme.id],
color_scheme_id: 1,
user_selectable: true,
},
}
expect(response.status).to eq(200)
expect(theme.reload.user_selectable).to be true
expect(theme.child_theme_ids).to eq([child_theme.id])
expect(theme.color_scheme_id).to eq(1)
put "/admin/themes/#{theme.id}.json",
params: {
theme: {
child_theme_ids: [child_theme.id],
name: "my test name",
user_selectable: false,
theme_fields: [
{ name: "scss", target: "common", value: "" },
{ name: "scss", target: "desktop", value: "body{color: blue;}" },
],
},
}
expect(response.status).to eq(403)
expect(theme.reload.user_selectable).to be true
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(404)
end
it "deletes the field's javascript cache" do
theme.set_field(
target: :common,
name: :header,
value: '<script>console.log("test")</script>',
)
theme.save!
javascript_caches =
theme
.theme_fields
.find_by(target_id: Theme.targets[:common], name: :header)
.raw_javascript_caches
expect(javascript_caches.length).to eq(1)
delete "/admin/themes/#{theme.id}.json"
expect(response.status).to eq(204)
expect { theme.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect { javascript_caches[0].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
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
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("errors.messages.invalid_locale", invalid_locale: "foo"),
)
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)
expect(response.status).to eq(204)
end
it "does not destroy any themes if any of them is a system theme" do
theme.update_columns(id: -10)
expect do
delete "/admin/themes/bulk_destroy.json", params: { theme_ids: theme_ids }
end.not_to change { Theme.count }
expect(response.status).to eq(400)
expect(response.parsed_body["errors"]).to eq(
["Theme ids " + I18n.t("errors.messages.must_all_be_positive")],
)
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 }
expect(response.status).to eq(204)
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")
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.child_links.title.description" => "Title of the child link",
"links.child_links.title.label" => "Title",
"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
describe "#update_source" do
fab!(:theme)
let!(:other_repo) { setup_git_repo("about.json" => { name: "other-theme" }.to_json) }
let!(:other_repo_url) do
MockGitImporter.register("https://github.com/discourse/other-theme.git", other_repo)
end
context "when logged in as an admin" do
before { sign_in(admin) }
it "updates the remote theme source" do
theme.remote_theme =
RemoteTheme.create!(remote_url: repo_url, branch: "main", local_version: "abc")
theme.save!
put "/admin/themes/#{theme.id}/source.json",
params: {
remote_url: other_repo_url,
branch: "develop",
}
expect(response.status).to eq(200)
theme.reload
expect(theme.remote_theme.remote_url).to eq(other_repo_url)
expect(theme.remote_theme.branch).to eq("develop")
end
it "returns error for non-git theme" do
put "/admin/themes/#{theme.id}/source.json", params: { remote_url: other_repo_url }
expect(response.status).to eq(400)
end
it "returns error for zip-imported theme" do
theme.remote_theme = RemoteTheme.create!(remote_url: "")
theme.save!
put "/admin/themes/#{theme.id}/source.json", params: { remote_url: other_repo_url }
expect(response.status).to eq(400)
end
it "returns error for missing remote_url" do
theme.remote_theme = RemoteTheme.create!(remote_url: repo_url)
theme.save!
put "/admin/themes/#{theme.id}/source.json", params: { remote_url: "" }
expect(response.status).to eq(400)
end
it "returns error for disallowed repo when allowlist is configured" do
global_setting :allowed_theme_repos, repo_url
theme.remote_theme = RemoteTheme.create!(remote_url: repo_url)
theme.save!
put "/admin/themes/#{theme.id}/source.json",
params: {
remote_url: "https://github.com/not-allowed/theme.git",
}
expect(response.status).to eq(403)
expect(response.parsed_body["errors"]).to include(
I18n.t(
"themes.import_error.not_allowed_theme",
{ repo: "https://github.com/not-allowed/theme.git" },
),
)
end
it "can use SSH key from Redis" do
theme.remote_theme = RemoteTheme.create!(remote_url: repo_url, local_version: "abc")
theme.save!
Discourse.redis.setex("ssh_key_test_public_key", 1.hour, "test_private_key")
put "/admin/themes/#{theme.id}/source.json",
params: {
remote_url: other_repo_url,
public_key: "test_public_key",
}
expect(response.status).to eq(200)
expect(theme.reload.remote_theme.private_key).to eq("test_private_key")
end
it "returns error when SSH key has expired" do
theme.remote_theme = RemoteTheme.create!(remote_url: repo_url)
theme.save!
put "/admin/themes/#{theme.id}/source.json",
params: {
remote_url: other_repo_url,
public_key: "expired_key",
}
expect(response.status).to eq(422)
expect(response.parsed_body["errors"]).to include(
I18n.t("themes.import_error.ssh_key_gone"),
)
end
it "reverts changes when the new source cannot be fetched" do
theme.remote_theme =
RemoteTheme.create!(
remote_url: repo_url,
branch: "main",
private_key: "old_key",
local_version: "abc",
remote_version: "def",
commits_behind: 2,
)
theme.save!
Discourse.redis.setex("ssh_key_new_public_key", 1.hour, "new_key")
RemoteTheme.any_instance.expects(:reload).once
put "/admin/themes/#{theme.id}/source.json",
params: {
remote_url: "https://github.com/discourse/missing-theme.git",
branch: "develop",
public_key: "new_public_key",
}
expect(response.status).to eq(422)
expect(response.parsed_body["errors"]).to include(I18n.t("themes.import_error.git"))
theme.reload
expect(theme.remote_theme.remote_url).to eq(repo_url)
expect(theme.remote_theme.branch).to eq("main")
expect(theme.remote_theme.private_key).to eq("old_key")
expect(theme.remote_theme.local_version).to eq("abc")
expect(theme.remote_theme.remote_version).to eq("def")
expect(theme.remote_theme.commits_behind).to eq(2)
expect(theme.remote_theme.last_error_text).to eq(nil)
end
it "reverts changes when a settings migration fails" do
initial_repo =
setup_git_repo(
"about.json" => {
name: "migration-theme",
about_url: "https://original.example.com/about",
}.to_json,
"settings.yml" => <<~YAML,
some_setting:
type: string
default: default value
YAML
)
initial_repo_url =
MockGitImporter.register("https://example.com/migration-source-theme.git", initial_repo)
migration_theme = RemoteTheme.import_theme(initial_repo_url)
migration_theme.remote_theme.update!(branch: "main", private_key: "old_key")
original_remote_version = migration_theme.remote_theme.remote_version
original_local_version = migration_theme.remote_theme.local_version
replacement_repo =
setup_git_repo(
"about.json" => {
name: "migration-theme",
about_url: "https://updated.example.com/about",
}.to_json,
"settings.yml" => <<~YAML,
some_setting:
type: string
default: default value
YAML
"migrations/settings/0001-bad-migration.js" => <<~JS,
export default function migrate(settings) {
return null;
}
JS
)
replacement_repo_url =
MockGitImporter.register(
"https://example.com/migration-replacement-theme.git",
replacement_repo,
)
Discourse.redis.setex("ssh_key_migration_public_key", 1.hour, "new_key")
put "/admin/themes/#{migration_theme.id}/source.json",
params: {
remote_url: replacement_repo_url,
branch: "develop",
public_key: "migration_public_key",
}
expect(response.status).to eq(422)
expect(response.parsed_body["errors"]).to include(
I18n.t("themes.import_error.migrations.no_returned_value", name: "0001-bad-migration"),
)
migration_theme.reload
expect(migration_theme.remote_theme.remote_url).to eq(initial_repo_url)
expect(migration_theme.remote_theme.branch).to eq("main")
expect(migration_theme.remote_theme.private_key).to eq("old_key")
expect(migration_theme.remote_theme.about_url).to eq("https://original.example.com/about")
expect(migration_theme.remote_theme.remote_version).to eq(original_remote_version)
expect(migration_theme.remote_theme.local_version).to eq(original_local_version)
expect(migration_theme.remote_theme.commits_behind).to eq(0)
end
it "reverts changes on import failure" do
theme.remote_theme =
RemoteTheme.create!(remote_url: repo_url, branch: "main", private_key: "old_key")
theme.save!
RemoteTheme
.any_instance
.stubs(:update_from_remote)
.raises(RemoteTheme::ImportError.new("error message"))
put "/admin/themes/#{theme.id}/source.json",
params: {
remote_url: other_repo_url,
branch: "develop",
}
expect(response.status).to eq(422)
theme.reload
expect(theme.remote_theme.remote_url).to eq(repo_url)
expect(theme.remote_theme.branch).to eq("main")
expect(theme.remote_theme.private_key).to eq("old_key")
end
end
shared_examples "source update not allowed" do
it "prevents source update with a 404 response" do
theme.remote_theme = RemoteTheme.create!(remote_url: repo_url)
theme.save!
put "/admin/themes/#{theme.id}/source.json", params: { remote_url: other_repo_url }
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 "source update not allowed"
end
context "when logged in as a non-staff user" do
before { sign_in(user) }
include_examples "source update not allowed"
end
end
describe "#show" do
let(:theme) { Fabricate(:theme) }
it "allows base_url in setting description" do
set_subfolder "/community"
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
my_setting:
default: true
description: This is a link to %{base_path}/example
YAML
theme.save!
sign_in admin
get "/admin/themes/#{theme.id}"
expect(response.status).to eq(200)
expect(response.parsed_body.dig("theme", "settings", 0, "description")).to eq(
"This is a link to /community/example",
)
end
it "skips interpolation for unknown variables" do
set_subfolder "/community"
theme.set_field(target: :settings, name: "yaml", value: <<~YAML)
my_setting:
default: true
description: Description %{some_mistake}
YAML
theme.save!
sign_in admin
get "/admin/themes/#{theme.id}"
expect(response.status).to eq(200)
expect(response.parsed_body.dig("theme", "settings", 0, "description")).to eq(
"Description %{some_mistake}",
)
end
end
end