diff --git a/lib/livebook/secrets.ex b/lib/livebook/secrets.ex new file mode 100644 index 000000000..027dedec5 --- /dev/null +++ b/lib/livebook/secrets.ex @@ -0,0 +1,113 @@ +defmodule Livebook.Secrets do + @moduledoc false + + import Ecto.Changeset, only: [apply_action: 2] + + alias Livebook.Secrets.Secret + + defmodule NotFoundError do + @moduledoc false + + defexception [:message, plug_status: 404] + end + + defp storage() do + Livebook.Storage.current() + end + + @doc """ + Get the secrets list from storage. + """ + @spec fetch_secrets() :: list(Secret.t()) + def fetch_secrets() do + for fields <- storage().all(:secrets) do + struct!(Secret, Map.delete(fields, :id)) + end + |> Enum.sort() + end + + @doc """ + Gets a secret from storage. + Raises `RuntimeError` if the secret doesn't exist. + """ + @spec fetch_secret!(String.t()) :: Secret.t() + def fetch_secret!(id) do + case storage().fetch(:secrets, id) do + :error -> + raise NotFoundError, "could not find the secret matching #{inspect(id)}" + + {:ok, fields} -> + struct!(Secret, Map.delete(fields, :id)) + end + end + + @doc """ + Checks if the secret already exists. + """ + @spec secret_exists?(String.t()) :: boolean() + def secret_exists?(id) do + storage().fetch(:secrets, id) != :error + end + + @doc """ + Sets the given secret. + """ + @spec set_secret(Secret.t() | %Secret{}, map()) :: + {:ok, Secret.t()} | {:error, Ecto.Changeset.t()} + def set_secret(%Secret{} = secret \\ %Secret{}, attrs) do + changeset = Secret.changeset(secret, attrs) + + with {:ok, secret} <- apply_action(changeset, :insert) do + save_secret(secret) + end + end + + defp save_secret(secret) do + attributes = secret |> Map.from_struct() |> Map.to_list() + + with :ok <- storage().insert(:secrets, secret.name, attributes), + :ok <- broadcast_secrets_change({:set_secret, secret}) do + {:ok, secret} + end + end + + @doc """ + Unset secret from given id. + """ + @spec unset_secret(String.t()) :: :ok + def unset_secret(id) do + if secret_exists?(id) do + secret = fetch_secret!(id) + storage().delete(:secrets, id) + broadcast_secrets_change({:unset_secret, secret}) + end + + :ok + end + + @doc """ + Subscribe to secrets updates. + + ## Messages + + * `{:set_secret, secret}` + * `{:unset_secret, secret}` + + """ + @spec subscribe() :: :ok | {:error, term()} + def subscribe do + Phoenix.PubSub.subscribe(Livebook.PubSub, "secrets") + end + + @doc """ + Unsubscribes from `subscribe/0`. + """ + @spec unsubscribe() :: :ok + def unsubscribe do + Phoenix.PubSub.unsubscribe(Livebook.PubSub, "secrets") + end + + defp broadcast_secrets_change(message) do + Phoenix.PubSub.broadcast(Livebook.PubSub, "secrets", message) + end +end diff --git a/lib/livebook/secrets/secret.ex b/lib/livebook/secrets/secret.ex new file mode 100644 index 000000000..57e18a8fd --- /dev/null +++ b/lib/livebook/secrets/secret.ex @@ -0,0 +1,25 @@ +defmodule Livebook.Secrets.Secret do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{ + name: String.t(), + value: String.t() + } + + @primary_key {:name, :string, autogenerate: false} + embedded_schema do + field :value, :string + end + + def changeset(secret, attrs \\ %{}) do + secret + |> cast(attrs, [:name, :value]) + |> update_change(:name, &String.upcase/1) + |> validate_format(:name, ~r/^\w+$/, + message: "should contain only alphanumeric and underscore" + ) + |> validate_required([:name, :value]) + end +end diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index edd26dd7c..1f9b89808 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -510,6 +510,14 @@ defmodule Livebook.Session do GenServer.cast(pid, {:put_secret, self(), secret}) end + @doc """ + Sends a secret deletion request to the server. + """ + @spec delete_secret(pid(), map()) :: :ok + def delete_secret(pid, secret_name) do + GenServer.cast(pid, {:delete_secret, self(), secret_name}) + end + @doc """ Sends save request to the server. @@ -978,6 +986,12 @@ defmodule Livebook.Session do {:noreply, handle_operation(state, operation)} end + def handle_cast({:delete_secret, client_pid, secret_name}, state) do + client_id = client_id(state, client_pid) + operation = {:delete_secret, client_id, secret_name} + {:noreply, handle_operation(state, operation)} + end + def handle_cast(:save, state) do {:noreply, maybe_save_notebook_async(state)} end @@ -1438,7 +1452,12 @@ defmodule Livebook.Session do end defp after_operation(state, _prev_state, {:put_secret, _client_id, secret}) do - if Runtime.connected?(state.data.runtime), do: set_runtime_secrets(state, [secret]) + if Runtime.connected?(state.data.runtime), do: set_runtime_secret(state, secret) + state + end + + defp after_operation(state, _prev_state, {:delete_secret, _client_id, secret_name}) do + if Runtime.connected?(state.data.runtime), do: delete_runtime_secrets(state, [secret_name]) state end @@ -1570,11 +1589,21 @@ defmodule Livebook.Session do put_in(state.memory_usage, %{runtime: runtime, system: Livebook.SystemResources.memory()}) end + defp set_runtime_secret(state, secret) do + secret = {"LB_#{secret.name}", secret.value} + Runtime.put_system_envs(state.data.runtime, [secret]) + end + defp set_runtime_secrets(state, secrets) do - secrets = Enum.map(secrets, &{"LB_#{&1.name}", &1.value}) + secrets = Enum.map(secrets, fn {name, value} -> {"LB_#{name}", value} end) Runtime.put_system_envs(state.data.runtime, secrets) end + defp delete_runtime_secrets(state, secret_names) do + secret_names = Enum.map(secret_names, &"LB_#{&1}") + Runtime.delete_system_envs(state.data.runtime, secret_names) + end + defp set_runtime_env_vars(state) do env_vars = Enum.map(Livebook.Settings.fetch_env_vars(), &{&1.name, &1.value}) Runtime.put_system_envs(state.data.runtime, env_vars) diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex index 1439d23d2..83d9dab95 100644 --- a/lib/livebook/session/data.ex +++ b/lib/livebook/session/data.ex @@ -122,7 +122,7 @@ defmodule Livebook.Session.Data do @type index :: non_neg_integer() - @type secret :: %{label: String.t(), value: String.t()} + @type secret :: %{name: String.t(), value: String.t()} # Snapshot holds information about the cell evaluation dependencies, # for example what is the previous cell, the number of times the @@ -194,6 +194,7 @@ defmodule Livebook.Session.Data do | {:set_autosave_interval, client_id(), non_neg_integer() | nil} | {:mark_as_not_dirty, client_id()} | {:put_secret, client_id(), secret()} + | {:delete_secret, client_id(), String.t()} @type action :: :connect_runtime @@ -222,7 +223,7 @@ defmodule Livebook.Session.Data do smart_cell_definitions: [], clients_map: %{}, users_map: %{}, - secrets: [] + secrets: %{} } data @@ -770,6 +771,13 @@ defmodule Livebook.Session.Data do |> wrap_ok() end + def apply_operation(data, {:delete_secret, _client_id, secret_name}) do + data + |> with_actions() + |> delete_secret(secret_name) + |> wrap_ok() + end + # === defp with_actions(data, actions \\ []), do: {data, actions} @@ -1508,16 +1516,12 @@ defmodule Livebook.Session.Data do end defp put_secret({data, _} = data_actions, secret) do - idx = Enum.find_index(data.secrets, &(&1.name == secret.name)) - - secrets = - if idx do - put_in(data.secrets, [Access.at(idx), :value], secret.value) - else - data.secrets ++ [secret] - end - |> Enum.sort() + secrets = Map.put(data.secrets, secret.name, secret.value) + set!(data_actions, secrets: secrets) + end + defp delete_secret({data, _} = data_actions, secret_name) do + secrets = Map.delete(data.secrets, secret_name) set!(data_actions, secrets: secrets) end diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index e927514ac..bf2de1635 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -5,7 +5,7 @@ defmodule LivebookWeb.SessionLive do import LivebookWeb.SessionHelpers import Livebook.Utils, only: [format_bytes: 1] - alias Livebook.{Sessions, Session, Delta, Notebook, Runtime, LiveMarkdown} + alias Livebook.{Sessions, Session, Delta, Notebook, Runtime, LiveMarkdown, Secrets} alias Livebook.Notebook.{Cell, ContentLoader} alias Livebook.JSInterop @@ -21,6 +21,7 @@ defmodule LivebookWeb.SessionLive do Session.register_client(session_pid, self(), socket.assigns.current_user) Session.subscribe(session_id) + Secrets.subscribe() {data, client_id} else @@ -55,7 +56,8 @@ defmodule LivebookWeb.SessionLive do platform: platform, data_view: data_to_view(data), autofocus_cell_id: autofocus_cell_id(data.notebook), - page_title: get_page_title(data.notebook.name) + page_title: get_page_title(data.notebook.name), + livebook_secrets: Secrets.fetch_secrets() |> Map.new(&{&1.name, &1.value}) ) |> assign_private(data: data) |> prune_outputs() @@ -172,7 +174,12 @@ defmodule LivebookWeb.SessionLive do <.clients_list data_view={@data_view} client_id={@client_id} />
- <.secrets_list data_view={@data_view} session={@session} socket={@socket} /> + <.secrets_list + data_view={@data_view} + livebook_secrets={@livebook_secrets} + session={@session} + socket={@socket} + />
<.runtime_info data_view={@data_view} session={@session} socket={@socket} /> @@ -405,9 +412,9 @@ defmodule LivebookWeb.SessionLive do id="secrets" session={@session} secrets={@data_view.secrets} + livebook_secrets={@livebook_secrets} prefill_secret_name={@prefill_secret_name} select_secret_ref={@select_secret_ref} - preselect_name={@preselect_name} select_secret_options={@select_secret_options} return_to={@self_path} /> @@ -553,17 +560,37 @@ defmodule LivebookWeb.SessionLive do

Secrets

+ Available to this notebook
- <%= for secret <- @data_view.secrets do %> + <%= for {secret_name, _} <- session_only_secrets(@data_view.secrets, @livebook_secrets) do %>
- - <%= secret.name %> + + <%= secret_name %> Session
<% end %> +
+
+ Stored in your Livebook + On session +
+ <%= for {secret_name, secret_value} = secret <- Enum.sort(@livebook_secrets) do %> +
+ + <%= secret_name %> + + <.switch_checkbox + name="toggle_secret" + checked={is_secret_on_session?(secret, @data_view.secrets)} + phx-click="toggle_secret" + phx-value-secret_name={secret_name} + phx-value-secret_value={secret_value} + /> +
+ <% end %>
<%= live_patch to: Routes.session_path(@socket, :secrets, @session.id), class: "inline-flex items-center justify-center p-8 py-1 mt-8 space-x-2 text-sm font-medium text-gray-500 border border-gray-400 border-dashed rounded-xl hover:bg-gray-100", @@ -758,8 +785,7 @@ defmodule LivebookWeb.SessionLive do when socket.assigns.live_action == :secrets do {:noreply, assign(socket, - prefill_secret_name: params["secret_name"], - preselect_name: params["preselect_name"], + prefill_secret_name: params["secret_name"] || params["preselect_name"], select_secret_ref: if(params["preselect_name"], do: socket.assigns.select_secret_ref), select_secret_options: if(params["preselect_name"], do: socket.assigns.select_secret_options) @@ -1147,6 +1173,21 @@ defmodule LivebookWeb.SessionLive do )} end + def handle_event( + "toggle_secret", + %{"secret-name" => secret_name, "secret-value" => secret_value, "value" => "true"}, + socket + ) do + secret = %{name: secret_name, value: secret_value} + Livebook.Session.put_secret(socket.assigns.session.pid, secret) + {:noreply, socket} + end + + def handle_event("toggle_secret", %{"secret-name" => secret_name}, socket) do + Livebook.Session.delete_secret(socket.assigns.session.pid, secret_name) + {:noreply, socket} + end + @impl true def handle_info({:operation, operation}, socket) do {:noreply, handle_operation(socket, operation)} @@ -1223,6 +1264,12 @@ defmodule LivebookWeb.SessionLive do {:noreply, socket} end + def handle_info({:set_secret, secret}, socket) do + livebook_secrets = Map.put(socket.assigns.livebook_secrets, secret.name, secret.value) + + {:noreply, assign(socket, livebook_secrets: livebook_secrets)} + end + def handle_info(_message, socket), do: {:noreply, socket} defp handle_relative_path(socket, path, requested_url) do @@ -1951,4 +1998,12 @@ defmodule LivebookWeb.SessionLive do :ok end + + defp session_only_secrets(secrets, livebook_secrets) do + Enum.reject(secrets, &(&1 in livebook_secrets)) |> Enum.sort() + end + + defp is_secret_on_session?(secret, secrets) do + secret in secrets + end end diff --git a/lib/livebook_web/live/session_live/secrets_component.ex b/lib/livebook_web/live/session_live/secrets_component.ex index 577a1a186..8d0a8bc32 100644 --- a/lib/livebook_web/live/session_live/secrets_component.ex +++ b/lib/livebook_web/live/session_live/secrets_component.ex @@ -4,14 +4,17 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do @impl true def update(assigns, socket) do socket = assign(socket, assigns) + prefill_form = prefill_secret_name(socket) socket = if socket.assigns[:data] do socket else assign(socket, - data: %{"name" => prefill_secret_name(socket), "value" => ""}, - title: title(socket) + data: %{"name" => prefill_form, "value" => "", "store" => "session"}, + title: title(socket), + grant_access: must_grant_access(socket), + has_prefill: prefill_form != "" ) end @@ -25,30 +28,36 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do

<%= @title %>

+ <%= if @grant_access do %> + <.grant_access_message grant_access={@grant_access} target={@myself} /> + <% end %>
<%= if @select_secret_ref do %>
-

+

Choose a secret

-
- <%= for secret <- @secrets do %> - <.choice_button - active={secret.name == @preselect_name} - value={secret.name} - phx-target={@myself} - phx-click="select_secret" - class={ - if secret.name == @preselect_name, - do: "text-xs rounded-full", - else: "text-xs rounded-full text-gray-700 hover:bg-gray-200" - } - > - <%= secret.name %> - +
+ <%= for {secret_name, _} <- Enum.sort(@secrets) do %> + <.secret_with_badge + secret_name={secret_name} + stored="Session" + action="select_secret" + active={secret_name == @prefill_secret_name} + target={@myself} + /> <% end %> - <%= if @secrets == [] do %> + <%= for {secret_name, _} <- livebook_only_secrets(@secrets, @livebook_secrets) do %> + <.secret_with_badge + secret_name={secret_name} + stored="livebook" + action="select_livebook_secret" + active={false} + target={@myself} + /> + <% end %> + <%= if @secrets == %{} and @livebook_secrets == %{} do %>
<.remix_icon icon="folder-lock-line" class="align-middle text-2xl" /> @@ -83,7 +92,7 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do <%= text_input(f, :name, value: @data["name"], class: "input", - autofocus: !@prefill_secret_name, + autofocus: !@has_prefill, spellcheck: "false" ) %> @@ -92,10 +101,21 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do <%= text_input(f, :value, value: @data["value"], class: "input", - autofocus: !!@prefill_secret_name || unavailable_secret?(@preselect_name, @secrets), + autofocus: @has_prefill, spellcheck: "false" ) %> +
+ Store +
+ <%= label class: "flex items-center gap-2 text-gray-600" do %> + <%= radio_button(f, :store, "session", checked: @data["store"] == "session") %> Session + <% end %> + <%= label class: "flex items-center gap-2 text-gray-600" do %> + <%= radio_button(f, :store, "livebook", checked: @data["store"] == "livebook") %> Notebook + <% end %> +
+
+
+
+
+
+ """ + end + @impl true def handle_event("save", %{"data" => data}, socket) do - secret_name = String.upcase(data["name"]) - if data_errors(data) == [] do + secret_name = String.upcase(data["name"]) secret = %{name: secret_name, value: data["value"]} - Livebook.Session.put_secret(socket.assigns.session.pid, secret) + store = data["store"] + + put_secret(socket.assigns.session.pid, secret, store) + + if store == "livebook" && + (socket.assigns.select_secret_ref || + {secret.name, socket.assigns.livebook_secrets[secret.name]} in socket.assigns.secrets) do + put_secret(socket.assigns.session.pid, secret, "session") + end {:noreply, socket |> push_patch(to: socket.assigns.return_to) |> push_secret_selected(secret_name)} @@ -125,7 +217,14 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do end end - def handle_event("select_secret", %{"value" => secret_name}, socket) do + def handle_event("select_secret", %{"secret_name" => secret_name}, socket) do + {:noreply, + socket |> push_patch(to: socket.assigns.return_to) |> push_secret_selected(secret_name)} + end + + def handle_event("select_livebook_secret", %{"secret_name" => secret_name}, socket) do + grant_access(secret_name, socket) + {:noreply, socket |> push_patch(to: socket.assigns.return_to) |> push_secret_selected(secret_name)} end @@ -134,6 +233,13 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do {:noreply, assign(socket, data: data)} end + def handle_event("grant_access", %{"secret_name" => secret_name}, socket) do + grant_access(secret_name, socket) + + {:noreply, + socket |> push_patch(to: socket.assigns.return_to) |> push_secret_selected(secret_name)} + end + defp data_errors(data) do Enum.flat_map(data, fn {key, value} -> if error = data_error(key, value) do @@ -162,25 +268,44 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do end defp prefill_secret_name(socket) do - case socket.assigns.prefill_secret_name do - nil -> - if unavailable_secret?(socket.assigns.preselect_name, socket.assigns.secrets), - do: socket.assigns.preselect_name, - else: "" - - prefill -> - prefill - end + if unavailable_secret?( + socket.assigns.prefill_secret_name, + socket.assigns.secrets, + socket.assigns.livebook_secrets + ), + do: socket.assigns.prefill_secret_name, + else: "" end - defp unavailable_secret?(nil, _), do: false - defp unavailable_secret?("", _), do: false + defp unavailable_secret?(nil, _, _), do: false + defp unavailable_secret?("", _, _), do: false - defp unavailable_secret?(preselect_name, secrets) do - preselect_name not in Enum.map(secrets, & &1.name) + defp unavailable_secret?(preselect_name, secrets, livebook_secrets) do + not Map.has_key?(secrets, preselect_name) and + not Map.has_key?(livebook_secrets, preselect_name) end defp title(%{assigns: %{select_secret_ref: nil}}), do: "Add secret" defp title(%{assigns: %{select_secret_options: %{"title" => title}}}), do: title defp title(_), do: "Select secret" + + defp put_secret(pid, secret, "session"), do: Livebook.Session.put_secret(pid, secret) + defp put_secret(_pid, secret, "livebook"), do: Livebook.Secrets.set_secret(secret) + + defp grant_access(secret_name, socket) do + secret_value = socket.assigns.livebook_secrets[secret_name] + secret = %{name: secret_name, value: secret_value} + put_secret(socket.assigns.session.pid, secret, "session") + end + + defp livebook_only_secrets(secrets, livebook_secrets) do + Enum.reject(livebook_secrets, &(&1 in secrets)) |> Enum.sort() + end + + defp must_grant_access(%{assigns: %{prefill_secret_name: prefill_secret_name}} = socket) do + if not Map.has_key?(socket.assigns.secrets, prefill_secret_name) and + Map.has_key?(socket.assigns.livebook_secrets, prefill_secret_name) do + prefill_secret_name + end + end end diff --git a/test/livebook/secrets_test.exs b/test/livebook/secrets_test.exs new file mode 100644 index 000000000..66e73b4ea --- /dev/null +++ b/test/livebook/secrets_test.exs @@ -0,0 +1,70 @@ +defmodule Livebook.SecretsTest do + use ExUnit.Case + use Livebook.DataCase + + alias Livebook.Secrets + alias Livebook.Secrets.Secret + + test "fetch secrets" do + Secrets.set_secret(%{name: "FOO", value: "111"}) + assert %Secret{name: "FOO", value: "111"} in Secrets.fetch_secrets() + + Secrets.unset_secret("FOO") + refute %Secret{name: "FOO", value: "111"} in Secrets.fetch_secrets() + end + + test "fetch an specif secret" do + secret = %{name: "FOO", value: "111"} + Secrets.set_secret(secret) + + assert_raise Secrets.NotFoundError, + ~s(could not find the secret matching "NOT_HERE"), + fn -> + Secrets.fetch_secret!("NOT_HERE") + end + + assert Secrets.fetch_secret!(secret.name) == %Secret{name: "FOO", value: "111"} + Secrets.unset_secret(secret.name) + end + + test "secret_exists?/1" do + Secrets.unset_secret("FOO") + refute Secrets.secret_exists?("FOO") + Secrets.set_secret(%{name: "FOO", value: "111"}) + assert Secrets.secret_exists?("FOO") + Secrets.unset_secret("FOO") + end + + describe "set_secret/1" do + test "creates and stores a secret" do + attrs = %{name: "FOO", value: "111"} + assert {:ok, secret} = Secrets.set_secret(attrs) + + assert attrs.name == secret.name + assert attrs.value == secret.value + + Secrets.unset_secret(secret.name) + end + + test "updates an stored secret" do + secret = %Secret{name: "FOO", value: "111"} + attrs = %{value: "222"} + assert {:ok, updated_secret} = Secrets.set_secret(secret, attrs) + + assert secret.name == updated_secret.name + assert updated_secret.value == attrs.value + + Secrets.unset_secret(secret.name) + end + + test "returns changeset error" do + attrs = %{value: "111"} + assert {:error, changeset} = Secrets.set_secret(attrs) + assert "can't be blank" in errors_on(changeset).name + attrs = %{name: "@inavalid", value: "111"} + + assert {:error, changeset} = Secrets.set_secret(attrs) + assert "should contain only alphanumeric and underscore" in errors_on(changeset).name + end + end +end diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index b9dec6e37..4a78d087a 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -936,9 +936,65 @@ defmodule LivebookWeb.SessionLiveTest do view |> element(~s{form[phx-submit="save"]}) - |> render_submit(%{data: %{name: "foo", value: "123"}}) + |> render_submit(%{data: %{name: "foo", value: "123", store: "session"}}) - assert %{secrets: [%{name: "FOO", value: "123"}]} = Session.get_data(session.pid) + assert %{secrets: %{"FOO" => "123"}} = Session.get_data(session.pid) + end + + test "adds a livebook secret from form", %{conn: conn, session: session} do + {:ok, view, _} = live(conn, "/sessions/#{session.id}/secrets") + + view + |> element(~s{form[phx-submit="save"]}) + |> render_submit(%{data: %{name: "bar", value: "456", store: "livebook"}}) + + assert %Livebook.Secrets.Secret{name: "BAR", value: "456"} in Livebook.Secrets.fetch_secrets() + end + + test "sync secrets when they're equal", %{conn: conn, session: session} do + Livebook.Secrets.set_secret(%{name: "FOO", value: "123"}) + {:ok, view, _} = live(conn, "/sessions/#{session.id}/secrets") + Session.put_secret(session.pid, %{name: "FOO", value: "123"}) + + view + |> element(~s{form[phx-submit="save"]}) + |> render_submit(%{data: %{name: "FOO", value: "456", store: "livebook"}}) + + assert %{secrets: %{"FOO" => "456"}} = Session.get_data(session.pid) + + assert %Livebook.Secrets.Secret{name: "FOO", value: "456"} in Livebook.Secrets.fetch_secrets() + end + + test "doesn't sync secrets when they are not the same", %{conn: conn, session: session} do + Livebook.Secrets.set_secret(%{name: "FOO_BAR", value: "456"}) + {:ok, view, _} = live(conn, "/sessions/#{session.id}/secrets") + Session.put_secret(session.pid, %{name: "FOO_BAR", value: "123"}) + + view + |> element(~s{form[phx-submit="save"]}) + |> render_submit(%{data: %{name: "FOO_BAR", value: "999", store: "livebook"}}) + + assert %{secrets: %{"FOO_BAR" => "123"}} = Session.get_data(session.pid) + + assert %Livebook.Secrets.Secret{name: "FOO_BAR", value: "999"} in Livebook.Secrets.fetch_secrets() + + refute %Livebook.Secrets.Secret{name: "FOO_BAR", value: "456"} in Livebook.Secrets.fetch_secrets() + end + + test "never sync secrets when updating from session", %{conn: conn, session: session} do + Livebook.Secrets.set_secret(%{name: "FOO", value: "123"}) + {:ok, view, _} = live(conn, "/sessions/#{session.id}/secrets") + Session.put_secret(session.pid, %{name: "FOO", value: "123"}) + + view + |> element(~s{form[phx-submit="save"]}) + |> render_submit(%{data: %{name: "FOO", value: "456", store: "session"}}) + + assert %{secrets: %{"FOO" => "456"}} = Session.get_data(session.pid) + + refute %Livebook.Secrets.Secret{name: "FOO", value: "456"} in Livebook.Secrets.fetch_secrets() + + assert %Livebook.Secrets.Secret{name: "FOO", value: "123"} in Livebook.Secrets.fetch_secrets() end test "shows the 'Add secret' button for unavailable secrets", %{conn: conn, session: session} do