Merge branch 'frontend-admin-api' into 'develop'
authorlain <lain@soykaf.club>
Wed, 18 Nov 2020 17:51:57 +0000 (17:51 +0000)
committerlain <lain@soykaf.club>
Wed, 18 Nov 2020 17:51:57 +0000 (17:51 +0000)
Add an API to manage frontends

Closes #2238

See merge request pleroma/pleroma!3108

CHANGELOG.md
docs/API/admin_api.md
lib/mix/tasks/pleroma/frontend.ex
lib/pleroma/frontend.ex [new file with mode: 0644]
lib/pleroma/web/admin_api/controllers/frontend_controller.ex [new file with mode: 0644]
lib/pleroma/web/admin_api/views/frontend_view.ex [new file with mode: 0644]
lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex [new file with mode: 0644]
lib/pleroma/web/router.ex
test/pleroma/frontend_test.exs [new file with mode: 0644]
test/pleroma/web/admin_api/controllers/frontend_controller_test.exs [new file with mode: 0644]

index fe1114c02d189660110863913bd57eda6a9690fe..616f9deeb2237c30ffc3c83a8bcf61cde2b40032 100644 (file)
@@ -28,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Pleroma API: Add `idempotency_key` to the chat message entity that can be used for optimistic message sending.
 - Pleroma API: (`GET /api/v1/pleroma/federation_status`) Add a way to get a list of unreachable instances.
 - Mastodon API: User and conversation mutes can now auto-expire if `expires_in` parameter was given while adding the mute.
+- Admin API: An endpoint to manage frontends
 
 </details>
 
index f7b5bcae77394a5854c3271d64ddbb60eba89531..19ac6a65f51e0dc49f08f87246f0a0a77bf50885 100644 (file)
@@ -1499,3 +1499,66 @@ Returns the content of the document
   "url": "https://example.com/instance/panel.html"
 }
 ```
+
+## `GET /api/pleroma/admin/frontends
+
+### List available frontends
+
+- Response:
+
+```json
+[
+   {
+    "build_url": "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build",
+    "git": "https://git.pleroma.social/pleroma/fedi-fe",
+    "installed": true,
+    "name": "fedi-fe",
+    "ref": "master"
+  },
+  {
+    "build_url": "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build",
+    "git": "https://git.pleroma.social/lambadalambda/kenoma",
+    "installed": false,
+    "name": "kenoma",
+    "ref": "master"
+  }
+]
+```
+
+## `POST /api/pleroma/admin/frontends/install`
+
+### Install a frontend
+
+- Params:
+  - `name`: frontend name, required
+  - `ref`: frontend ref
+  - `file`: path to a frontend zip file
+  - `build_url`: build URL
+  - `build_dir`: build directory
+
+- Response:
+
+```json
+[
+   {
+    "build_url": "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build",
+    "git": "https://git.pleroma.social/pleroma/fedi-fe",
+    "installed": true,
+    "name": "fedi-fe",
+    "ref": "master"
+  },
+  {
+    "build_url": "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build",
+    "git": "https://git.pleroma.social/lambadalambda/kenoma",
+    "installed": false,
+    "name": "kenoma",
+    "ref": "master"
+  }
+]
+```
+
+```json
+{
+  "error": "Could not install frontend"
+}
+```
index cbce81ab9666919d0f163a36c612d0482892f98a..f15dbc38bd4cbf8f4f750f3751011861dbdbeb02 100644 (file)
@@ -17,8 +17,6 @@ defmodule Mix.Tasks.Pleroma.Frontend do
   end
 
   def run(["install", frontend | args]) do
-    log_level = Logger.level()
-    Logger.configure(level: :warn)
     start_pleroma()
 
     {options, [], []} =
@@ -33,109 +31,6 @@ defmodule Mix.Tasks.Pleroma.Frontend do
         ]
       )
 
-    instance_static_dir =
-      with nil <- options[:static_dir] do
-        Pleroma.Config.get!([:instance, :static_dir])
-      end
-
-    cmd_frontend_info = %{
-      "name" => frontend,
-      "ref" => options[:ref],
-      "build_url" => options[:build_url],
-      "build_dir" => options[:build_dir]
-    }
-
-    config_frontend_info = Pleroma.Config.get([:frontends, :available, frontend], %{})
-
-    frontend_info =
-      Map.merge(config_frontend_info, cmd_frontend_info, fn _key, config, cmd ->
-        # This only overrides things that are actually set
-        cmd || config
-      end)
-
-    ref = frontend_info["ref"]
-
-    unless ref do
-      raise "No ref given or configured"
-    end
-
-    dest =
-      Path.join([
-        instance_static_dir,
-        "frontends",
-        frontend,
-        ref
-      ])
-
-    fe_label = "#{frontend} (#{ref})"
-
-    tmp_dir = Path.join([instance_static_dir, "frontends", "tmp"])
-
-    with {_, :ok} <-
-           {:download_or_unzip, download_or_unzip(frontend_info, tmp_dir, options[:file])},
-         shell_info("Installing #{fe_label} to #{dest}"),
-         :ok <- install_frontend(frontend_info, tmp_dir, dest) do
-      File.rm_rf!(tmp_dir)
-      shell_info("Frontend #{fe_label} installed to #{dest}")
-
-      Logger.configure(level: log_level)
-    else
-      {:download_or_unzip, _} ->
-        shell_info("Could not download or unzip the frontend")
-
-      _e ->
-        shell_info("Could not install the frontend")
-    end
-  end
-
-  defp download_or_unzip(frontend_info, temp_dir, file) do
-    if file do
-      with {:ok, zip} <- File.read(Path.expand(file)) do
-        unzip(zip, temp_dir)
-      end
-    else
-      download_build(frontend_info, temp_dir)
-    end
-  end
-
-  def unzip(zip, dest) do
-    with {:ok, unzipped} <- :zip.unzip(zip, [:memory]) do
-      File.rm_rf!(dest)
-      File.mkdir_p!(dest)
-
-      Enum.each(unzipped, fn {filename, data} ->
-        path = filename
-
-        new_file_path = Path.join(dest, path)
-
-        new_file_path
-        |> Path.dirname()
-        |> File.mkdir_p!()
-
-        File.write!(new_file_path, data)
-      end)
-
-      :ok
-    end
-  end
-
-  defp download_build(frontend_info, dest) do
-    shell_info("Downloading pre-built bundle for #{frontend_info["name"]}")
-    url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"])
-
-    with {:ok, %{status: 200, body: zip_body}} <-
-           Pleroma.HTTP.get(url, [], pool: :media, recv_timeout: 120_000) do
-      unzip(zip_body, dest)
-    else
-      e -> {:error, e}
-    end
-  end
-
-  defp install_frontend(frontend_info, source, dest) do
-    from = frontend_info["build_dir"] || "dist"
-    File.rm_rf!(dest)
-    File.mkdir_p!(dest)
-    File.cp_r!(Path.join([source, from]), dest)
-    :ok
+    Pleroma.Frontend.install(frontend, options)
   end
 end
diff --git a/lib/pleroma/frontend.ex b/lib/pleroma/frontend.ex
new file mode 100644 (file)
index 0000000..bf935a7
--- /dev/null
@@ -0,0 +1,110 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Frontend do
+  alias Pleroma.Config
+
+  require Logger
+
+  def install(name, opts \\ []) do
+    frontend_info = %{
+      "ref" => opts[:ref],
+      "build_url" => opts[:build_url],
+      "build_dir" => opts[:build_dir]
+    }
+
+    frontend_info =
+      [:frontends, :available, name]
+      |> Config.get(%{})
+      |> Map.merge(frontend_info, fn _key, config, cmd ->
+        # This only overrides things that are actually set
+        cmd || config
+      end)
+
+    ref = frontend_info["ref"]
+
+    unless ref do
+      raise "No ref given or configured"
+    end
+
+    dest = Path.join([dir(), name, ref])
+
+    label = "#{name} (#{ref})"
+    tmp_dir = Path.join(dir(), "tmp")
+
+    with {_, :ok} <-
+           {:download_or_unzip, download_or_unzip(frontend_info, tmp_dir, opts[:file])},
+         Logger.info("Installing #{label} to #{dest}"),
+         :ok <- install_frontend(frontend_info, tmp_dir, dest) do
+      File.rm_rf!(tmp_dir)
+      Logger.info("Frontend #{label} installed to #{dest}")
+    else
+      {:download_or_unzip, _} ->
+        Logger.info("Could not download or unzip the frontend")
+        {:error, "Could not download or unzip the frontend"}
+
+      _e ->
+        Logger.info("Could not install the frontend")
+        {:error, "Could not install the frontend"}
+    end
+  end
+
+  def dir(opts \\ []) do
+    if is_nil(opts[:static_dir]) do
+      Pleroma.Config.get!([:instance, :static_dir])
+    else
+      opts[:static_dir]
+    end
+    |> Path.join("frontends")
+  end
+
+  defp download_or_unzip(frontend_info, temp_dir, nil),
+    do: download_build(frontend_info, temp_dir)
+
+  defp download_or_unzip(_frontend_info, temp_dir, file) do
+    with {:ok, zip} <- File.read(Path.expand(file)) do
+      unzip(zip, temp_dir)
+    end
+  end
+
+  def unzip(zip, dest) do
+    with {:ok, unzipped} <- :zip.unzip(zip, [:memory]) do
+      File.rm_rf!(dest)
+      File.mkdir_p!(dest)
+
+      Enum.each(unzipped, fn {filename, data} ->
+        path = filename
+
+        new_file_path = Path.join(dest, path)
+
+        new_file_path
+        |> Path.dirname()
+        |> File.mkdir_p!()
+
+        File.write!(new_file_path, data)
+      end)
+    end
+  end
+
+  defp download_build(frontend_info, dest) do
+    Logger.info("Downloading pre-built bundle for #{frontend_info["name"]}")
+    url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"])
+
+    with {:ok, %{status: 200, body: zip_body}} <-
+           Pleroma.HTTP.get(url, [], pool: :media, recv_timeout: 120_000) do
+      unzip(zip_body, dest)
+    else
+      {:error, e} -> {:error, e}
+      e -> {:error, e}
+    end
+  end
+
+  defp install_frontend(frontend_info, source, dest) do
+    from = frontend_info["build_dir"] || "dist"
+    File.rm_rf!(dest)
+    File.mkdir_p!(dest)
+    File.cp_r!(Path.join([source, from]), dest)
+    :ok
+  end
+end
diff --git a/lib/pleroma/web/admin_api/controllers/frontend_controller.ex b/lib/pleroma/web/admin_api/controllers/frontend_controller.ex
new file mode 100644 (file)
index 0000000..fac3522
--- /dev/null
@@ -0,0 +1,40 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.AdminAPI.FrontendController do
+  use Pleroma.Web, :controller
+
+  alias Pleroma.Config
+  alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+  plug(OAuthScopesPlug, %{scopes: ["write"], admin: true} when action == :install)
+  plug(OAuthScopesPlug, %{scopes: ["read"], admin: true} when action == :index)
+  action_fallback(Pleroma.Web.AdminAPI.FallbackController)
+
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.FrontendOperation
+
+  def index(conn, _params) do
+    installed = installed()
+
+    frontends =
+      [:frontends, :available]
+      |> Config.get([])
+      |> Enum.map(fn {name, desc} ->
+        Map.put(desc, "installed", name in installed)
+      end)
+
+    render(conn, "index.json", frontends: frontends)
+  end
+
+  def install(%{body_params: params} = conn, _params) do
+    with :ok <- Pleroma.Frontend.install(params.name, Map.delete(params, :name)) do
+      index(conn, %{})
+    end
+  end
+
+  defp installed do
+    File.ls!(Pleroma.Frontend.dir())
+  end
+end
diff --git a/lib/pleroma/web/admin_api/views/frontend_view.ex b/lib/pleroma/web/admin_api/views/frontend_view.ex
new file mode 100644 (file)
index 0000000..374841d
--- /dev/null
@@ -0,0 +1,21 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.AdminAPI.FrontendView do
+  use Pleroma.Web, :view
+
+  def render("index.json", %{frontends: frontends}) do
+    render_many(frontends, __MODULE__, "show.json")
+  end
+
+  def render("show.json", %{frontend: frontend}) do
+    %{
+      name: frontend["name"],
+      git: frontend["git"],
+      build_url: frontend["build_url"],
+      ref: frontend["ref"],
+      installed: frontend["installed"]
+    }
+  end
+end
diff --git a/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex b/lib/pleroma/web/api_spec/operations/admin/frontend_operation.ex
new file mode 100644 (file)
index 0000000..96d4cde
--- /dev/null
@@ -0,0 +1,85 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Admin.FrontendOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Admin", "Reports"],
+      summary: "Get a list of available frontends",
+      operationId: "AdminAPI.FrontendController.index",
+      security: [%{"oAuth" => ["read"]}],
+      responses: %{
+        200 => Operation.response("Response", "application/json", list_of_frontends()),
+        403 => Operation.response("Forbidden", "application/json", ApiError)
+      }
+    }
+  end
+
+  def install_operation do
+    %Operation{
+      tags: ["Admin", "Reports"],
+      summary: "Install a frontend",
+      operationId: "AdminAPI.FrontendController.install",
+      security: [%{"oAuth" => ["read"]}],
+      requestBody: request_body("Parameters", install_request(), required: true),
+      responses: %{
+        200 => Operation.response("Response", "application/json", list_of_frontends()),
+        403 => Operation.response("Forbidden", "application/json", ApiError),
+        400 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp list_of_frontends do
+    %Schema{
+      type: :array,
+      items: %Schema{
+        type: :object,
+        properties: %{
+          name: %Schema{type: :string},
+          git: %Schema{type: :string, format: :uri, nullable: true},
+          build_url: %Schema{type: :string, format: :uri, nullable: true},
+          ref: %Schema{type: :string},
+          installed: %Schema{type: :boolean}
+        }
+      }
+    }
+  end
+
+  defp install_request do
+    %Schema{
+      title: "FrontendInstallRequest",
+      type: :object,
+      required: [:name],
+      properties: %{
+        name: %Schema{
+          type: :string
+        },
+        ref: %Schema{
+          type: :string
+        },
+        file: %Schema{
+          type: :string
+        },
+        build_url: %Schema{
+          type: :string
+        },
+        build_dir: %Schema{
+          type: :string
+        }
+      }
+    }
+  end
+end
index 0f0538182624e957aa59d129d910963840b3376b..75a88537758f239bede91e85420e12221dade4fa 100644 (file)
@@ -244,6 +244,9 @@ defmodule Pleroma.Web.Router do
     get("/chats/:id/messages", ChatController, :messages)
     delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
 
+    get("/frontends", FrontendController, :index)
+    post("/frontends/install", FrontendController, :install)
+
     post("/backups", AdminAPIController, :create_backup)
   end
 
diff --git a/test/pleroma/frontend_test.exs b/test/pleroma/frontend_test.exs
new file mode 100644 (file)
index 0000000..2236258
--- /dev/null
@@ -0,0 +1,72 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.FrontendTest do
+  use Pleroma.DataCase
+  alias Pleroma.Frontend
+
+  @dir "test/frontend_static_test"
+
+  setup do
+    File.mkdir_p!(@dir)
+    clear_config([:instance, :static_dir], @dir)
+
+    on_exit(fn ->
+      File.rm_rf(@dir)
+    end)
+  end
+
+  test "it downloads and unzips a known frontend" do
+    clear_config([:frontends, :available], %{
+      "pleroma" => %{
+        "ref" => "fantasy",
+        "name" => "pleroma",
+        "build_url" => "http://gensokyo.2hu/builds/${ref}"
+      }
+    })
+
+    Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/builds/fantasy"} ->
+      %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend_dist.zip")}
+    end)
+
+    Frontend.install("pleroma")
+
+    assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"]))
+  end
+
+  test "it also works given a file" do
+    clear_config([:frontends, :available], %{
+      "pleroma" => %{
+        "ref" => "fantasy",
+        "name" => "pleroma",
+        "build_dir" => ""
+      }
+    })
+
+    folder = Path.join([@dir, "frontends", "pleroma", "fantasy"])
+    previously_existing = Path.join([folder, "temp"])
+    File.mkdir_p!(folder)
+    File.write!(previously_existing, "yey")
+    assert File.exists?(previously_existing)
+
+    Frontend.install("pleroma", file: "test/fixtures/tesla_mock/frontend.zip")
+
+    assert File.exists?(Path.join([folder, "test.txt"]))
+    refute File.exists?(previously_existing)
+  end
+
+  test "it downloads and unzips unknown frontends" do
+    Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/madeup.zip"} ->
+      %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend.zip")}
+    end)
+
+    Frontend.install("unknown",
+      ref: "baka",
+      build_url: "http://gensokyo.2hu/madeup.zip",
+      build_dir: ""
+    )
+
+    assert File.exists?(Path.join([@dir, "frontends", "unknown", "baka", "test.txt"]))
+  end
+end
diff --git a/test/pleroma/web/admin_api/controllers/frontend_controller_test.exs b/test/pleroma/web/admin_api/controllers/frontend_controller_test.exs
new file mode 100644 (file)
index 0000000..94873f6
--- /dev/null
@@ -0,0 +1,141 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.AdminAPI.FrontendControllerTest do
+  use Pleroma.Web.ConnCase
+
+  import Pleroma.Factory
+
+  alias Pleroma.Config
+
+  @dir "test/frontend_static_test"
+
+  setup do
+    clear_config([:instance, :static_dir], @dir)
+    File.mkdir_p!(Pleroma.Frontend.dir())
+
+    on_exit(fn ->
+      File.rm_rf(@dir)
+    end)
+
+    admin = insert(:user, is_admin: true)
+    token = insert(:oauth_admin_token, user: admin)
+
+    conn =
+      build_conn()
+      |> assign(:user, admin)
+      |> assign(:token, token)
+
+    {:ok, %{admin: admin, token: token, conn: conn}}
+  end
+
+  describe "GET /api/pleroma/admin/frontends" do
+    test "it lists available frontends", %{conn: conn} do
+      response =
+        conn
+        |> get("/api/pleroma/admin/frontends")
+        |> json_response_and_validate_schema(:ok)
+
+      assert Enum.map(response, & &1["name"]) ==
+               Enum.map(Config.get([:frontends, :available]), fn {_, map} -> map["name"] end)
+
+      refute Enum.any?(response, fn frontend -> frontend["installed"] == true end)
+    end
+  end
+
+  describe "POST /api/pleroma/admin/frontends/install" do
+    test "from available frontends", %{conn: conn} do
+      clear_config([:frontends, :available], %{
+        "pleroma" => %{
+          "ref" => "fantasy",
+          "name" => "pleroma",
+          "build_url" => "http://gensokyo.2hu/builds/${ref}"
+        }
+      })
+
+      Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/builds/fantasy"} ->
+        %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend_dist.zip")}
+      end)
+
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> post("/api/pleroma/admin/frontends/install", %{name: "pleroma"})
+      |> json_response_and_validate_schema(:ok)
+
+      assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"]))
+
+      response =
+        conn
+        |> get("/api/pleroma/admin/frontends")
+        |> json_response_and_validate_schema(:ok)
+
+      assert response == [
+               %{
+                 "build_url" => "http://gensokyo.2hu/builds/${ref}",
+                 "git" => nil,
+                 "installed" => true,
+                 "name" => "pleroma",
+                 "ref" => "fantasy"
+               }
+             ]
+    end
+
+    test "from a file", %{conn: conn} do
+      clear_config([:frontends, :available], %{
+        "pleroma" => %{
+          "ref" => "fantasy",
+          "name" => "pleroma",
+          "build_dir" => ""
+        }
+      })
+
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> post("/api/pleroma/admin/frontends/install", %{
+        name: "pleroma",
+        file: "test/fixtures/tesla_mock/frontend.zip"
+      })
+      |> json_response_and_validate_schema(:ok)
+
+      assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"]))
+    end
+
+    test "from an URL", %{conn: conn} do
+      Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/madeup.zip"} ->
+        %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend.zip")}
+      end)
+
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> post("/api/pleroma/admin/frontends/install", %{
+        name: "unknown",
+        ref: "baka",
+        build_url: "http://gensokyo.2hu/madeup.zip",
+        build_dir: ""
+      })
+      |> json_response_and_validate_schema(:ok)
+
+      assert File.exists?(Path.join([@dir, "frontends", "unknown", "baka", "test.txt"]))
+    end
+
+    test "failing returns an error", %{conn: conn} do
+      Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/madeup.zip"} ->
+        %Tesla.Env{status: 404, body: ""}
+      end)
+
+      result =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/pleroma/admin/frontends/install", %{
+          name: "unknown",
+          ref: "baka",
+          build_url: "http://gensokyo.2hu/madeup.zip",
+          build_dir: ""
+        })
+        |> json_response_and_validate_schema(400)
+
+      assert result == %{"error" => "Could not download or unzip the frontend"}
+    end
+  end
+end