From e029a9b36c26092f579dc116bdefd75f3e6ad46d Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Wed, 10 Aug 2022 13:30:18 +0300 Subject: [PATCH] FEATURE: Allow private themes to be partially installed (#17644) A public key must be added to GitHub when installing private themes. When the process happens asynchronously (for example if the admin does not have admin permissions to the GitHub repository), installing private themes becomes very difficult. In this case, the Discourse admin can partially install the theme by letting Discourse save the private key, create a placeholder theme and give the admin a public key to be used as a deploy key. After the key is installed, the admin can finish theme installation by pressing a button on the theme page. --- .../admin-customize-themes-show.js | 9 + .../controllers/modals/admin-install-theme.js | 25 +- .../addon/templates/customize-themes-show.hbs | 541 +++++++++--------- .../templates/modal/admin-install-theme.hbs | 7 +- .../discourse/tests/acceptance/themes-test.js | 243 ++++++++ .../stylesheets/common/admin/customize.scss | 5 +- app/controllers/admin/themes_controller.rb | 18 +- app/models/remote_theme.rb | 7 + config/locales/client.en.yml | 4 + spec/requests/admin/themes_controller_spec.rb | 19 + 10 files changed, 617 insertions(+), 261 deletions(-) create mode 100644 app/assets/javascripts/discourse/tests/acceptance/themes-test.js diff --git a/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js b/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js index f527051f512..2418c785e0b 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js @@ -167,6 +167,15 @@ export default Controller.extend({ return errorMessage && !updating; }, + @discourseComputed( + "model.remote_theme.remote_url", + "model.remote_theme.local_version", + "model.remote_theme.commits_behind" + ) + finishInstall(remoteUrl, localVersion, commitsBehind) { + return remoteUrl && !localVersion && !commitsBehind; + }, + editedFieldsForTarget(target) { return this.get("model.editedFields").filter( (field) => field.target === target diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-install-theme.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-install-theme.js index df91acd0943..02907601804 100644 --- a/app/assets/javascripts/admin/addon/controllers/modals/admin-install-theme.js +++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-install-theme.js @@ -119,8 +119,12 @@ export default Controller.extend(ModalFunctionality, { } }, - @discourseComputed("selection") - submitLabel(selection) { + @discourseComputed("selection", "themeCannotBeInstalled") + submitLabel(selection, themeCannotBeInstalled) { + if (themeCannotBeInstalled) { + return "admin.customize.theme.create_placeholder"; + } + return `admin.customize.theme.${ selection === "create" ? "create" : "install" }`; @@ -216,6 +220,12 @@ export default Controller.extend(ModalFunctionality, { } } + // User knows that theme cannot be installed, but they want to continue + // to force install it. + if (this.themeCannotBeInstalled) { + options.data["force"] = true; + } + if (this.get("model.user_id")) { // Used by theme-creator options.data["user_id"] = this.get("model.user_id"); @@ -231,7 +241,16 @@ export default Controller.extend(ModalFunctionality, { .then(() => { this.setProperties({ privateKey: null, publicKey: null }); }) - .catch(popupAjaxError) + .catch((error) => { + if (!this.privateKey || this.themeCannotBeInstalled) { + return popupAjaxError(error); + } + + this.set( + "themeCannotBeInstalled", + I18n.t("admin.customize.theme.force_install") + ); + }) .finally(() => this.set("loading", false)); }, }, diff --git a/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs index 31690ff15f4..4fe285d33eb 100644 --- a/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs +++ b/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs @@ -15,281 +15,312 @@
{{error}}
{{/each}} - {{#unless this.model.supported}} -
- {{i18n "admin.customize.theme.required_version.error"}} - {{#if this.model.remote_theme.minimum_discourse_version}} - {{i18n "admin.customize.theme.required_version.minimum" version=this.model.remote_theme.minimum_discourse_version}} - {{/if}} - {{#if this.model.remote_theme.maximum_discourse_version}} - {{i18n "admin.customize.theme.required_version.maximum" version=this.model.remote_theme.maximum_discourse_version}} - {{/if}} -
- {{/unless}} - - {{#unless this.model.enabled}} -
- {{#if this.model.disabled_by}} - {{i18n "admin.customize.theme.disabled_by"}} - - {{avatar this.model.disabled_by imageSize="tiny"}} - {{this.model.disabled_by.username}} - - {{format-date this.model.disabled_at leaveAgo="true"}} + {{#if this.finishInstall}} +
+ {{#if this.sourceIsHttp}} + {{i18n "admin.customize.theme.source_url"}}{{d-icon "link"}} {{else}} - {{i18n "admin.customize.theme.disabled"}} - {{/if}} - -
- {{/unless}} - - - - {{#if this.showCheckboxes}} -
- {{#unless this.model.component}} - - - {{/unless}} - {{#if this.model.remote_theme}} - - {{/if}}
- {{/if}} - - {{#unless this.model.component}} - -
-
- {{i18n "admin.customize.theme.color_scheme"}} -
-
- - -
{{i18n "admin.customize.theme.color_scheme_select"}}
-
-
- {{#if this.colorSchemeChanged}} - - - {{/if}} -
-
-
- {{/unless}} - - {{#if this.parentThemes}} -
-
{{i18n "admin.customize.theme.component_of"}}
-
    - {{#each this.parentThemes as |theme|}} -
  • {{theme.name}}
  • - {{/each}} -
-
- {{/if}} - - {{#if this.model.component}} - -
- -
-
{{else}} - -
- + {{#unless this.model.supported}} +
+ {{i18n "admin.customize.theme.required_version.error"}} + {{#if this.model.remote_theme.minimum_discourse_version}} + {{i18n "admin.customize.theme.required_version.minimum" version=this.model.remote_theme.minimum_discourse_version}} + {{/if}} + {{#if this.model.remote_theme.maximum_discourse_version}} + {{i18n "admin.customize.theme.required_version.maximum" version=this.model.remote_theme.maximum_discourse_version}} + {{/if}}
- - {{/if}} + {{/unless}} - {{#unless this.model.remote_theme.is_git}} -
-
{{i18n "admin.customize.theme.css_html"}}
- {{#if this.model.hasEditedFields}} -
{{i18n "admin.customize.theme.custom_sections"}}
-
    - {{#each this.editedFieldsFormatted as |field|}} -
  • {{field}}
  • - {{/each}} -
- {{else}} -
- {{i18n "admin.customize.theme.edit_css_html_help"}} -
- {{/if}} + {{#unless this.model.enabled}} +
+ {{#if this.model.disabled_by}} + {{i18n "admin.customize.theme.disabled_by"}} + + {{avatar this.model.disabled_by imageSize="tiny"}} + {{this.model.disabled_by.username}} + + {{format-date this.model.disabled_at leaveAgo="true"}} + {{else}} + {{i18n "admin.customize.theme.disabled"}} + {{/if}} + +
+ {{/unless}} - -
- -
-
{{i18n "admin.customize.theme.uploads"}}
- {{#if this.model.uploads}} -
    - {{#each this.model.uploads as |upload|}} -
  • - ${{upload.name}}: {{upload.filename}} - - - -
  • - {{/each}} -
- {{else}} -
{{i18n "admin.customize.theme.no_uploads"}}
- {{/if}} - -
- {{/unless}} - - {{#if this.extraFiles.length}} -
-
{{i18n "admin.customize.theme.extra_files"}}
-
- - {{#if this.model.remote_theme}} - {{i18n "admin.customize.theme.extra_files_remote"}} + + {{/if}} + {{#if this.model.remote_theme.about_url}} + {{i18n "admin.customize.theme.about_theme"}}{{d-icon "link"}} + {{/if}} + {{#if this.model.remote_theme.license_url}} + {{i18n "admin.customize.theme.license"}}{{d-icon "link"}} + {{/if}} + + {{#if this.model.description}} + {{this.model.description}} + {{/if}} + + {{#if this.model.remote_theme.authors}}{{i18n "admin.customize.theme.authors"}} {{this.model.remote_theme.authors}}{{/if}} + {{#if this.model.remote_theme.theme_version}}{{i18n "admin.customize.theme.version"}} {{this.model.remote_theme.theme_version}}{{/if}} + +
+ {{#if this.model.remote_theme.is_git}} +
+ {{html-safe (i18n "admin.customize.theme.remote_theme_edits" repoURL=this.remoteThemeLink)}} +
+ + {{#if this.showRemoteError}} +
+ {{d-icon "exclamation-triangle"}} {{i18n "admin.customize.theme.repo_unreachable"}} +
+
+ {{this.model.remoteError}} +
+ {{/if}} + + {{#if this.model.remote_theme.commits_behind}} + + {{else}} + + {{/if}} + + + {{#if this.updatingRemote}} + {{i18n "admin.customize.theme.updating"}} + {{else}} + {{#if this.model.remote_theme.commits_behind}} + {{#if this.hasOverwrittenHistory}} + {{i18n "admin.customize.theme.has_overwritten_history"}} + {{else}} + {{i18n "admin.customize.theme.commits_behind" count=this.model.remote_theme.commits_behind}} + {{/if}} + {{#if this.model.remote_theme.github_diff_link}} + + {{i18n "admin.customize.theme.compare_commits"}} + + {{/if}} + {{else}} + {{#unless this.showRemoteError}} + {{i18n "admin.customize.theme.up_to_date"}} {{format-date this.model.remote_theme.updated_at leaveAgo="true"}} + {{/unless}} + {{/if}} + {{/if}} + + {{else}} + + {{d-icon "info-circle"}} {{i18n "admin.customize.theme.imported_from_archive"}} + + {{/if}} +
+ {{else}} + {{i18n "admin.customize.theme.creator"}} + + + {{format-username this.model.user.username}} + + + {{/if}} +
+ + {{#if this.showCheckboxes}} +
+ {{#unless this.model.component}} + + + {{/unless}} + {{#if this.model.remote_theme}} + + {{/if}} +
+ {{/if}} + + {{#unless this.model.component}} + +
+
+ {{i18n "admin.customize.theme.color_scheme"}} +
+
+ + +
{{i18n "admin.customize.theme.color_scheme_select"}}
+
+
+ {{#if this.colorSchemeChanged}} + + + {{/if}} +
+
+
+ {{/unless}} + + {{#if this.parentThemes}} +
+
{{i18n "admin.customize.theme.component_of"}}
    - {{#each this.extraFiles as |extraFile|}} -
  • {{extraFile.name}}
  • + {{#each this.parentThemes as |theme|}} +
  • {{theme.name}}
  • {{/each}}
- -
- {{/if}} - - {{#if this.hasSettings}} -
-
{{i18n "admin.customize.theme.theme_settings"}}
- - {{#each this.settings as |setting|}} - - {{/each}} - -
- {{/if}} - - {{#if this.hasTranslations}} -
-
{{i18n "admin.customize.theme.theme_translations"}}
- - {{#each this.translations as |translation|}} - - {{/each}} - -
- {{/if}} - - {{/if}} {{#if this.model.component}} - {{#if this.model.enabled}} - - {{else}} - - {{/if}} + +
+ +
+
+ {{else}} + +
+ +
+
{{/if}} - + {{#unless this.model.remote_theme.is_git}} +
+
{{i18n "admin.customize.theme.css_html"}}
+ {{#if this.model.hasEditedFields}} +
{{i18n "admin.customize.theme.custom_sections"}}
+
    + {{#each this.editedFieldsFormatted as |field|}} +
  • {{field}}
  • + {{/each}} +
+ {{else}} +
+ {{i18n "admin.customize.theme.edit_css_html_help"}} +
+ {{/if}} -
+ +
+ +
+
{{i18n "admin.customize.theme.uploads"}}
+ {{#if this.model.uploads}} +
    + {{#each this.model.uploads as |upload|}} +
  • + ${{upload.name}}: {{upload.filename}} + + + +
  • + {{/each}} +
+ {{else}} +
{{i18n "admin.customize.theme.no_uploads"}}
+ {{/if}} + +
+ {{/unless}} + + {{#if this.extraFiles.length}} +
+
{{i18n "admin.customize.theme.extra_files"}}
+
+ + {{#if this.model.remote_theme}} + {{i18n "admin.customize.theme.extra_files_remote"}} + {{else}} + {{i18n "admin.customize.theme.extra_files_upload"}} + {{/if}} + +
    + {{#each this.extraFiles as |extraFile|}} +
  • {{extraFile.name}}
  • + {{/each}} +
+
+
+ {{/if}} + + {{#if this.hasSettings}} +
+
{{i18n "admin.customize.theme.theme_settings"}}
+ + {{#each this.settings as |setting|}} + + {{/each}} + +
+ {{/if}} + + {{#if this.hasTranslations}} +
+
{{i18n "admin.customize.theme.theme_translations"}}
+ + {{#each this.translations as |translation|}} + + {{/each}} + +
+ {{/if}} + +
+ + {{d-icon "desktop"}}{{i18n "admin.customize.theme.preview"}} + {{d-icon "download"}} {{i18n "admin.export_json.button_text"}} + + {{#if this.showConvert}} + + {{/if}} + + {{#if this.model.component}} + {{#if this.model.enabled}} + + {{else}} + + {{/if}} + {{/if}} + + + +
+ {{/if}}
diff --git a/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs b/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs index 0918c6aee4e..cebb9a6121f 100644 --- a/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs +++ b/app/assets/javascripts/admin/addon/templates/modal/admin-install-theme.hbs @@ -111,7 +111,12 @@ ⚠️ {{this.duplicateRemoteThemeWarning}} {{/if}} - + {{#if this.themeCannotBeInstalled}} +
+ ⚠️ {{this.themeCannotBeInstalled}} +
+ {{/if}} + {{/unless}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/themes-test.js b/app/assets/javascripts/discourse/tests/acceptance/themes-test.js new file mode 100644 index 00000000000..40595569dcc --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/themes-test.js @@ -0,0 +1,243 @@ +import { click, fillIn, visit } from "@ember/test-helpers"; +import { + acceptance, + exists, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import I18n from "I18n"; +import { test } from "qunit"; + +acceptance("Theme", function (needs) { + needs.user(); + + needs.pretender((server, helper) => { + server.get("/admin/themes", () => { + return helper.response(200, { + themes: [ + { + id: 42, + name: "discourse-incomplete-theme", + created_at: "2022-01-01T12:00:00.000Z", + updated_at: "2022-01-01T12:00:00.000Z", + component: false, + color_scheme: null, + color_scheme_id: null, + user_selectable: false, + auto_update: true, + remote_theme_id: 42, + settings: [], + supported: true, + description: null, + enabled: true, + user: { + id: 1, + username: "foo", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/f/3be4f8/{size}.png", + title: "Tester", + }, + theme_fields: [], + child_themes: [], + parent_themes: [], + remote_theme: { + id: 42, + remote_url: + "git@github.com:discourse/discourse-incomplete-theme.git", + remote_version: null, + local_version: null, + commits_behind: null, + branch: null, + remote_updated_at: null, + updated_at: "2022-01-01T12:00:00.000Z", + last_error_text: null, + is_git: true, + license_url: null, + about_url: null, + authors: null, + theme_version: null, + minimum_discourse_version: null, + maximum_discourse_version: null, + }, + translations: [], + }, + ], + }); + }); + + server.post("/admin/themes/import", (request) => { + const data = helper.parsePostData(request.requestBody); + + if (!data.force) { + return helper.response(422, { + errors: [ + "Error cloning git repository, access is denied or repository is not found", + ], + }); + } + + return helper.response(201, { + theme: { + id: 42, + name: "discourse-inexistent-theme", + created_at: "2022-01-01T12:00:00.000Z", + updated_at: "2022-01-01T12:00:00.000Z", + component: false, + color_scheme: null, + color_scheme_id: null, + user_selectable: false, + auto_update: true, + remote_theme_id: 42, + settings: [], + supported: true, + description: null, + enabled: true, + user: { + id: 1, + username: "foo", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/f/3be4f8/{size}.png", + }, + theme_fields: [], + child_themes: [], + parent_themes: [], + remote_theme: { + id: 42, + remote_url: + "git@github.com:discourse/discourse-inexistent-theme.git", + remote_version: null, + local_version: null, + commits_behind: null, + branch: null, + remote_updated_at: null, + updated_at: "2022-01-01T12:00:00.000Z", + last_error_text: null, + is_git: true, + license_url: null, + about_url: null, + authors: null, + theme_version: null, + minimum_discourse_version: null, + maximum_discourse_version: null, + }, + translations: [], + }, + }); + }); + + server.put("/admin/themes/42", () => { + return helper.response(200, { + theme: { + id: 42, + name: "discourse-complete-theme", + created_at: "2022-01-01T12:00:00.000Z", + updated_at: "2022-01-01T12:00:00.000Z", + component: false, + color_scheme: null, + color_scheme_id: null, + user_selectable: false, + auto_update: true, + remote_theme_id: 42, + settings: [], + supported: true, + description: null, + enabled: true, + user: { + id: 1, + username: "foo", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/f/3be4f8/{size}.png", + }, + theme_fields: [], + child_themes: [], + parent_themes: [], + remote_theme: { + id: 42, + remote_url: + "git@github.com:discourse-org/discourse-incomplete-theme.git", + remote_version: "0000000000000000000000000000000000000000", + local_version: "0000000000000000000000000000000000000000", + commits_behind: 0, + branch: null, + remote_updated_at: "2022-01-01T12:00:30.000Z", + updated_at: "2022-01-01T12:00:30.000Z", + last_error_text: null, + is_git: true, + license_url: "URL", + about_url: "URL", + authors: null, + theme_version: null, + minimum_discourse_version: null, + maximum_discourse_version: null, + }, + translations: [], + }, + }); + }); + }); + + test("can force install themes", async function (assert) { + await visit("/admin/customize/themes"); + + await click(".themes-list .create-actions button"); + await click(".install-theme-items #remote"); + await fillIn( + ".install-theme-content .repo input", + "git@github.com:discourse/discourse-inexistent-theme.git" + ); + await click(".install-theme-content button.advanced-repo"); + await click(".install-theme-content .check-private input"); + + assert.notOk( + exists(".admin-install-theme-modal .modal-footer .install-theme-warning"), + "no Git warning is displayed" + ); + + await click(".admin-install-theme-modal .modal-footer .btn-primary"); + assert.ok( + exists(".admin-install-theme-modal .modal-footer .install-theme-warning"), + "Git warning is displayed" + ); + + await click(".admin-install-theme-modal .modal-footer .btn-danger"); + + assert.notOk( + exists(".admin-install-theme-modal:visible"), + "modal is closed" + ); + }); + + test("can continue installation", async function (assert) { + await visit("/admin/customize/themes"); + + await click(".themes-list-container .themes-list-item"); + assert.ok( + query(".control-unit .status-message").innerText.includes( + I18n.t("admin.customize.theme.last_attempt") + ), + "it says that theme is not completely installed" + ); + + await click(".control-unit .btn-primary.finish-install"); + + assert.equal( + query(".show-current-style .title span").innerText, + "discourse-complete-theme", + "it updates theme title" + ); + + assert.notOk( + query(".metadata.control-unit").innerText.includes( + I18n.t("admin.customize.theme.last_attempt") + ), + "it does not say that theme is not completely installed" + ); + + assert.notOk( + query(".control-unit .btn-primary.finish-install"), + "it does not show finish install button" + ); + }); +}); diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index 8109dcf7f48..2c876a8abf7 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -27,9 +27,12 @@ .admin-container { padding: 0; } - .error-message { + .error-message, + .raw-error { margin-top: 5px; margin-bottom: 5px; + } + .error-message { .fa { color: var(--danger); } diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index 428f8ff5795..50749db4777 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -104,7 +104,23 @@ class Admin::ThemesController < Admin::AdminController @theme = RemoteTheme.import_theme(remote, theme_user, private_key: params[:private_key], branch: branch) render json: @theme, status: :created rescue RemoteTheme::ImportError => e - render_json_error e.message + if params[:force] + theme_name = params[:remote].gsub(/.git$/, "").split("/").last + + remote_theme = RemoteTheme.new + remote_theme.private_key = params[:private_key] + remote_theme.branch = params[:branch] ? params[:branch] : nil + remote_theme.remote_url = params[:remote] + remote_theme.save! + + @theme = Theme.new(user_id: theme_user&.id || -1, name: theme_name) + @theme.remote_theme = remote_theme + @theme.save! + + render json: @theme, status: :created + else + render_json_error e.message + end end elsif params[:bundle] || (params[:theme] && THEME_CONTENT_TYPES.include?(params[:theme].content_type)) diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb index 21f6bef275b..f72f77c1bc7 100644 --- a/app/models/remote_theme.rb +++ b/app/models/remote_theme.rb @@ -171,6 +171,13 @@ class RemoteTheme < ActiveRecord::Base end end + # Update all theme attributes if this is just a placeholder + if self.remote_url.present? && !self.local_version && !self.commits_behind + self.theme.name = theme_info["name"] + self.theme.component = [true, "true"].include?(theme_info["component"]) + self.theme.child_components = theme_info["components"].presence || [] + end + METADATA_PROPERTIES.each do |property| self.public_send(:"#{property}=", theme_info[property.to_s]) end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 5365a990bc5..3b776812efb 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4719,6 +4719,8 @@ en: import_web_advanced: "Advanced..." import_file_tip: ".tar.gz, .zip, or .dcstyle.json file containing theme" is_private: "Theme is in a private git repository" + finish_install: "Finish Theme Installation" + last_attempt: "Installation process did not finish, last attempted:" remote_branch: "Branch name (optional)" public_key: "Grant the following public key access to the repo:" public_key_note: "After entering a valid private repository URL above, an SSH key will be generated and displayed here." @@ -4729,6 +4731,8 @@ en: install_git_repo: "From a git repository" install_create: "Create new" duplicate_remote_theme: "The theme component “%{name}” is already installed, are you sure you want to install another copy?" + force_install: "The theme cannot be installed because the Git repository is inaccessible. Are you sure you want to continue installing it?" + create_placeholder: "Create Placeholder" about_theme: "About" license: "License" version: "Version:" diff --git a/spec/requests/admin/themes_controller_spec.rb b/spec/requests/admin/themes_controller_spec.rb index a3cf8cca71a..e42e67787a0 100644 --- a/spec/requests/admin/themes_controller_spec.rb +++ b/spec/requests/admin/themes_controller_spec.rb @@ -151,6 +151,25 @@ RSpec.describe Admin::ThemesController do 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"