diff --git a/README.md b/README.md index e9c3d1689..8b0729b73 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,12 @@ The following environment variables can be used to configure Livebook on boot: standard schemes by default (such as http and https). Set it to a comma-separated list of schemes. + * LIVEBOOK_IDENTITY_PROVIDER - controls whether Zero Trust Identity is enabled. + Set it to your provider and the correspondent key to enable it. + Currently supported providers are Cloudflare and GoogleIap. + The respective keys are the team name (domain) for CloudFlare and the audience (aud) for GoogleIAP. + E.g. `"cloudflare:"`, `"googleiap:` + When running Livebook Desktop, Livebook will invoke on boot a file named diff --git a/lib/livebook.ex b/lib/livebook.ex index 467cc213e..c2b1373b9 100644 --- a/lib/livebook.ex +++ b/lib/livebook.ex @@ -194,6 +194,11 @@ defmodule Livebook do if allowed_uri_schemes = Livebook.Config.allowed_uri_schemes!("LIVEBOOK_ALLOW_URI_SCHEMES") do config :livebook, :allowed_uri_schemes, allowed_uri_schemes end + + config :livebook, + :identity_provider, + Livebook.Config.identity_provider!("LIVEBOOK_IDENTITY_PROVIDER") || + {LivebookWeb.Cookies, :unused} end @doc """ diff --git a/lib/livebook/application.ex b/lib/livebook/application.ex index 33eb16417..e485d76ce 100644 --- a/lib/livebook/application.ex +++ b/lib/livebook/application.ex @@ -44,6 +44,7 @@ defmodule Livebook.Application do {DynamicSupervisor, name: Livebook.HubsSupervisor, strategy: :one_for_one} ] ++ iframe_server_specs() ++ + identity_provider() ++ [ # Start the Endpoint (http/https) # We skip the access url as we do our own logging below @@ -267,4 +268,9 @@ defmodule Livebook.Application do "Failed to start Livebook iframe server because port #{port} is already in use" ) end + + defp identity_provider() do + {module, key} = Livebook.Config.identity_provider() + [{module, name: LivebookWeb.ZTA, identity: [key: key]}] + end end diff --git a/lib/livebook/config.ex b/lib/livebook/config.ex index 57d2884df..6ff4a666e 100644 --- a/lib/livebook/config.ex +++ b/lib/livebook/config.ex @@ -168,6 +168,22 @@ defmodule Livebook.Config do Application.fetch_env!(:livebook, :shutdown_callback) end + @doc """ + Returns the identity provider. + """ + @spec identity_provider() :: tuple() + def identity_provider() do + Application.fetch_env!(:livebook, :identity_provider) + end + + @doc """ + Returns if the identity data is readonly. + """ + @spec identity_readonly?() :: boolean() + def identity_readonly?() do + not match?({LivebookWeb.Cookies, _}, Livebook.Config.identity_provider()) + end + @doc """ Returns whether the application is running inside an iframe. """ @@ -493,4 +509,23 @@ defmodule Livebook.Config do IO.puts("\nERROR!!! [Livebook] " <> message) System.halt(1) end + + @doc """ + Parses zero trust identity provider from env. + """ + def identity_provider!(env) do + case System.get_env(env) do + "googleiap:" <> rest -> + {Livebook.ZTA.GoogleIAP, rest} + + "cloudflare:" <> rest -> + {Livebook.ZTA.Cloudflare, rest} + + nil -> + nil + + _ -> + abort!("invalid configuration for identity provider") + end + end end diff --git a/lib/livebook/zta/cloudflare.ex b/lib/livebook/zta/cloudflare.ex new file mode 100644 index 000000000..af1e1dfa9 --- /dev/null +++ b/lib/livebook/zta/cloudflare.ex @@ -0,0 +1,95 @@ +defmodule Livebook.ZTA.Cloudflare do + @moduledoc false + + use GenServer + require Logger + import Plug.Conn + + @assertion "cf-access-jwt-assertion" + @renew_afer 24 * 60 * 60 * 1000 + + defstruct [:name, :req_options, :identity] + + def start_link(opts) do + identity = identity(opts[:identity][:key]) + options = [req_options: [url: identity.certs], identity: identity] + GenServer.start_link(__MODULE__, options, name: opts[:name]) + end + + def authenticate(name, conn) do + token = get_req_header(conn, @assertion) + GenServer.call(name, {:authenticate, token}) + end + + @impl true + def init(options) do + :ets.new(options[:name], [:public, :named_table]) + {:ok, struct!(__MODULE__, options)} + end + + @impl true + def handle_call(:get_keys, _from, state) do + keys = get_from_ets(state.name) || request_and_store_in_ets(state) + {:reply, keys, state} + end + + def handle_call({:authenticate, token}, _from, state) do + keys = get_from_ets(state.name) || request_and_store_in_ets(state) + user = authenticate(token, state.identity, keys) + {:reply, user, state} + end + + @impl true + def handle_info(:request, state) do + request_and_store_in_ets(state) + {:noreply, state} + end + + defp request_and_store_in_ets(state) do + Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}") + keys = Req.request!(state.req_options).body["keys"] + :ets.insert(state.name, keys: keys) + Process.send_after(self(), :request, @renew_afer) + keys + end + + defp get_from_ets(name) do + case :ets.lookup(name, :keys) do + [keys: keys] -> keys + [] -> nil + end + end + + defp authenticate(token, identity, keys) do + with [token] <- token, + {:ok, token} <- verify_token(token, keys), + :ok <- verify_iss(token, identity.iss) do + %{name: token.fields["email"]} + else + _ -> nil + end + end + + defp verify_token(token, keys) do + Enum.find_value(keys, fn key -> + case JOSE.JWT.verify(key, token) do + {true, token, _s} -> {:ok, token} + {_, _t, _s} -> nil + end + end) + end + + defp verify_iss(%{fields: %{"iss" => iss}}, iss), do: :ok + defp verify_iss(_, _), do: nil + + defp identity(key) do + %{ + key: key, + key_type: "domain", + iss: "https://#{key}.cloudflareaccess.com", + certs: "https://#{key}.cloudflareaccess.com/cdn-cgi/access/certs", + assertion: "cf-access-jwt-assertion", + email: "cf-access-authenticated-user-email" + } + end +end diff --git a/lib/livebook/zta/googleiap.ex b/lib/livebook/zta/googleiap.ex new file mode 100644 index 000000000..eaf55f638 --- /dev/null +++ b/lib/livebook/zta/googleiap.ex @@ -0,0 +1,95 @@ +defmodule Livebook.ZTA.GoogleIAP do + @moduledoc false + + use GenServer + require Logger + import Plug.Conn + + @assertion "cf-access-jwt-assertion" + @renew_afer 24 * 60 * 60 * 1000 + + defstruct [:name, :req_options, :identity] + + def start_link(opts) do + identity = identity(opts[:identity][:key]) + options = [req_options: [url: identity.certs], identity: identity] + GenServer.start_link(__MODULE__, options, name: opts[:name]) + end + + def authenticate(name, conn) do + token = get_req_header(conn, @assertion) + GenServer.call(name, {:authenticate, token}) + end + + @impl true + def init(options) do + :ets.new(options[:name], [:public, :named_table]) + {:ok, struct!(__MODULE__, options)} + end + + @impl true + def handle_call(:get_keys, _from, state) do + keys = get_from_ets(state.name) || request_and_store_in_ets(state) + {:reply, keys, state} + end + + def handle_call({:authenticate, token}, _from, state) do + keys = get_from_ets(state.name) || request_and_store_in_ets(state) + user = authenticate(token, state.identity, keys) + {:reply, user, state} + end + + @impl true + def handle_info(:request, state) do + request_and_store_in_ets(state) + {:noreply, state} + end + + defp request_and_store_in_ets(state) do + Logger.debug("[#{inspect(__MODULE__)}] requesting #{inspect(state.req_options)}") + keys = Req.request!(state.req_options).body["keys"] + :ets.insert(state.name, keys: keys) + Process.send_after(self(), :request, @renew_afer) + keys + end + + defp get_from_ets(name) do + case :ets.lookup(name, :keys) do + [keys: keys] -> keys + [] -> nil + end + end + + defp authenticate(token, identity, keys) do + with [token] <- token, + {:ok, token} <- verify_token(token, keys), + :ok <- verify_iss(token, identity.iss) do + %{name: token.fields["email"]} + else + _ -> nil + end + end + + defp verify_token(token, keys) do + Enum.find_value(keys, fn key -> + case JOSE.JWT.verify(key, token) do + {true, token, _s} -> {:ok, token} + {_, _t, _s} -> nil + end + end) + end + + defp verify_iss(%{fields: %{"iss" => iss}}, iss), do: :ok + defp verify_iss(_, _), do: nil + + defp identity(key) do + %{ + key: key, + key_type: "aud", + iss: "https://cloud.google.com/iap", + certs: "https://www.gstatic.com/iap/verify/public_key", + assertion: "x-goog-iap-jwt-assertion", + email: "x-goog-authenticated-user-email" + } + end +end diff --git a/lib/livebook_web/controllers/error_html/403.html.heex b/lib/livebook_web/controllers/error_html/403.html.heex new file mode 100644 index 000000000..402c77d30 --- /dev/null +++ b/lib/livebook_web/controllers/error_html/403.html.heex @@ -0,0 +1,24 @@ + + + + + + + + + <%= @status %> - Livebook + + + +
+
+ + livebook + +
+ No Numbats allowed here! +
+
+
+ + diff --git a/lib/livebook_web/cookies.ex b/lib/livebook_web/cookies.ex new file mode 100644 index 000000000..f7ce52dff --- /dev/null +++ b/lib/livebook_web/cookies.ex @@ -0,0 +1,12 @@ +defmodule LivebookWeb.Cookies do + # This module implements the ZTA contract specific to Livebook cookies + @moduledoc false + + def authenticate(_, _conn) do + %{} + end + + def child_spec(_opts) do + %{id: __MODULE__, start: {Function, :identity, [:ignore]}} + end +end diff --git a/lib/livebook_web/live/hooks/user_hook.ex b/lib/livebook_web/live/hooks/user_hook.ex index ba3b65c18..c66514435 100644 --- a/lib/livebook_web/live/hooks/user_hook.ex +++ b/lib/livebook_web/live/hooks/user_hook.ex @@ -35,9 +35,11 @@ defmodule LivebookWeb.UserHook do defp build_current_user(session, socket) do %{"current_user_id" => current_user_id} = session user = %{User.new() | id: current_user_id} + identity_data = Map.new(session["identity_data"], fn {k, v} -> {Atom.to_string(k), v} end) connect_params = get_connect_params(socket) || %{} attrs = connect_params["user_data"] || session["user_data"] || %{} + attrs = Map.merge(attrs, identity_data) case Livebook.Users.update_user(user, attrs) do {:ok, user} -> user diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 2705cdd7d..ba3cfa046 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -1732,8 +1732,7 @@ defmodule LivebookWeb.SessionLive do end defp after_operation(socket, _prev_socket, {:insert_cell, client_id, _, _, _, cell_id, _attrs}) do - {:ok, cell, _section} = - Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) + {:ok, cell, _section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) socket = push_cell_editor_payloads(socket, socket.private.data, [cell]) @@ -1764,8 +1763,7 @@ defmodule LivebookWeb.SessionLive do end defp after_operation(socket, _prev_socket, {:restore_cell, client_id, cell_id}) do - {:ok, cell, _section} = - Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) + {:ok, cell, _section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id) socket = push_cell_editor_payloads(socket, socket.private.data, [cell]) diff --git a/lib/livebook_web/live/user_component.ex b/lib/livebook_web/live/user_component.ex index 9248f2432..c398e86b1 100644 --- a/lib/livebook_web/live/user_component.ex +++ b/lib/livebook_web/live/user_component.ex @@ -35,7 +35,12 @@ defmodule LivebookWeb.UserComponent do phx-hook="UserForm" >
- <.text_field field={f[:name]} label="Display name" spellcheck="false" /> + <.text_field + field={f[:name]} + label="Display name" + spellcheck="false" + disabled={Livebook.Config.identity_readonly?()} + /> <.hex_color_field field={f[:hex_color]} label="Cursor color" diff --git a/lib/livebook_web/plugs/user_plug.ex b/lib/livebook_web/plugs/user_plug.ex index 3c7093aba..8a58e61b3 100644 --- a/lib/livebook_web/plugs/user_plug.ex +++ b/lib/livebook_web/plugs/user_plug.ex @@ -16,6 +16,7 @@ defmodule LivebookWeb.UserPlug do @behaviour Plug import Plug.Conn + import Phoenix.Controller alias Livebook.Users.User @@ -26,6 +27,7 @@ defmodule LivebookWeb.UserPlug do def call(conn, _opts) do conn |> ensure_current_user_id() + |> ensure_user_identity() |> ensure_user_data() |> mirror_user_data_in_session() end @@ -39,11 +41,27 @@ defmodule LivebookWeb.UserPlug do end end + defp ensure_user_identity(conn) do + {module, _} = Livebook.Config.identity_provider() + identity_data = module.authenticate(LivebookWeb.ZTA, conn) + + if identity_data do + put_session(conn, :identity_data, identity_data) + else + conn + |> put_status(:forbidden) + |> put_view(LivebookWeb.ErrorHTML) + |> render("403.html") + |> halt() + end + end + defp ensure_user_data(conn) do if Map.has_key?(conn.req_cookies, "lb:user_data") do conn else - user_data = user_data(User.new()) + identity_data = get_session(conn, :identity_data) + user_data = User.new() |> user_data() |> Map.merge(identity_data) encoded = user_data |> Jason.encode!() |> Base.encode64() # We disable HttpOnly, so that it can be accessed on the client diff --git a/mix.exs b/mix.exs index e15ec362b..0107a656b 100644 --- a/mix.exs +++ b/mix.exs @@ -107,7 +107,10 @@ defmodule Livebook.MixProject do {:protobuf, "~> 0.8.0"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, {:floki, ">= 0.27.0", only: :test}, - {:bypass, "~> 2.1", only: :test} + {:bypass, "~> 2.1", only: :test}, + # ZTA deps + {:jose, "~> 1.11.5"}, + {:req, "~> 0.3.8"} ] end diff --git a/mix.lock b/mix.lock index ae7e71774..f10ea1662 100644 --- a/mix.lock +++ b/mix.lock @@ -9,12 +9,16 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, "ecto": {:hex, :ecto, "3.10.2", "6b887160281a61aa16843e47735b8a266caa437f80588c3ab80a8a960e6abe37", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6a895778f0d7648a4b34b486af59a1c8009041fbdf2b17f1ac215eb829c60235"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"}, "floki": {:hex, :floki, "0.34.3", "5e2dcaec5d7c228ce5b1d3501502e308b2d79eb655e4191751a1fe491c37feac", [:mix], [], "hexpm", "9577440eea5b97924b4bf3c7ea55f7b8b6dce589f9b28b096cc294a8dc342341"}, "hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "jose": {:hex, :jose, "1.11.5", "3bc2d75ffa5e2c941ca93e5696b54978323191988eb8d225c2e663ddfefd515e", [:mix, :rebar3], [], "hexpm", "dcd3b215bafe02ea7c5b23dafd3eb8062a5cd8f2d904fd9caa323d37034ab384"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.3", "aab42fff792a74649916236d0b01f560a0b3f03ca5dea693c230d1c44736b50e", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "ca3810ca44cc8532e3dce499cc17f958596695d226bb578b2fbb88c09b5954b0"}, + "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, + "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "phoenix": {:hex, :phoenix, "1.7.5", "3234bc87185e6a2103a15a3b1399f19775b093a6923c4064436e49cdab8ce5d2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.1", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "5abad1789f06a3572ee5e5d5151993ed35b9e2711537904cc457a40229587979"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.2", "b21bd01fdeffcfe2fab49e4942aa938b6d3e89e93a480d4aee58085560a0bc0d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "70242edd4601d50b69273b057ecf7b684644c19ee750989fd555625ae4ce8f5d"}, "phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"}, @@ -28,6 +32,7 @@ "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, "protobuf": {:hex, :protobuf, "0.8.0", "61b27d6fd50e7b1b2eb0ee17c1f639906121f4ef965ae0994644eb4c68d4647d", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3644ed846fd6f5e3b5c2cd617aa8344641e230edf812a45365fee7622bccd25a"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "req": {:hex, :req, "0.3.8", "e254074435c970b1d7699777f1a8466acbacab5e6ba4a264d35053bf52c03467", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a17244d18a7fbf3e9892c38c10628224f6f7974fd364392ca0d85f91e3cc8251"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, diff --git a/test/livebook/notebook/export/elixir_test.exs b/test/livebook/notebook/export/elixir_test.exs index 83b12eb70..59dfa2a69 100644 --- a/test/livebook/notebook/export/elixir_test.exs +++ b/test/livebook/notebook/export/elixir_test.exs @@ -135,32 +135,31 @@ defmodule Livebook.Notebook.Export.ElixirTest do end test "comments out non-elixir code cells" do - notebook = - %{ - Notebook.new() - | name: "My Notebook", - sections: [ - %{ - Notebook.Section.new() - | name: "Section 1", - cells: [ - %{ - Notebook.Cell.new(:code) - | source: """ - Enum.to_list(1..10)\ - """ - }, - %{ - Notebook.Cell.new(:code) - | language: :erlang, - source: """ - lists:seq(1, 10).\ - """ - } - ] - } - ] - } + notebook = %{ + Notebook.new() + | name: "My Notebook", + sections: [ + %{ + Notebook.Section.new() + | name: "Section 1", + cells: [ + %{ + Notebook.Cell.new(:code) + | source: """ + Enum.to_list(1..10)\ + """ + }, + %{ + Notebook.Cell.new(:code) + | language: :erlang, + source: """ + lists:seq(1, 10).\ + """ + } + ] + } + ] + } expected_document = """ # Run as: iex --dot-iex path/to/notebook.exs