Notebook secrets (#1457)

* Store secrets in the notebook

* Automatically grants access to secrets

* Put notebook secrets on session

* Shows secrets as a list

* Grant access message box

* Grant access confirmation on select

* Applying suggestions - grant access confirmation

* Handles unavailable secrets

* Toggle secrets

* Session only secrets

* Sync secrets

* Fix delete runtime secret

* Clean up

* Component helpers

* Does not store secrets in notebooks

* Store Livebook secrets

* Fix sync secrets

* Tests for secrets

* Doesn't refetch livebook_secrets

* Removes unused function

* More test for secrets

* Livebook secrets as maps

* Fix secret tests

* Applying suggestions

* All secrets as a map

* Shows grant access for missing secret errors

* Unifies grant access

* Fix set_runtime_secrets

* Applying suggestions

* Updates sync secrets tests

* Fix active secret

* Unifies prefill secret name
This commit is contained in:
Cristine Guadelupe 2022-10-06 13:41:26 -03:00 committed by GitHub
parent 8d7a077fe5
commit 4c83317453
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 539 additions and 62 deletions

113
lib/livebook/secrets.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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} />
</div>
<div data-el-secrets-list>
<.secrets_list data_view={@data_view} session={@session} socket={@socket} />
<.secrets_list
data_view={@data_view}
livebook_secrets={@livebook_secrets}
session={@session}
socket={@socket}
/>
</div>
<div data-el-runtime-info>
<.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
<h3 class="uppercase text-sm font-semibold text-gray-500">
Secrets
</h3>
<span class="mt-4 text-sm font-semibold text-gray-500">Available to this notebook</span>
<div class="flex flex-col mt-4 space-y-4">
<%= for secret <- @data_view.secrets do %>
<%= for {secret_name, _} <- session_only_secrets(@data_view.secrets, @livebook_secrets) do %>
<div class="flex justify-between items-center text-gray-500">
<span class="break-all">
<%= secret.name %>
<span class="text-sm break-all">
<%= secret_name %>
</span>
<span class="rounded-full bg-gray-200 px-2 text-xs text-gray-600">
Session
</span>
</div>
<% end %>
<div class="w-full border-t border-gray-300 py-1"></div>
<div class="flex justify-between mt-4">
<span class="text-sm font-semibold text-gray-500">Stored in your Livebook</span>
<span class="text-sm font-light text-gray-500">On session</span>
</div>
<%= for {secret_name, secret_value} = secret <- Enum.sort(@livebook_secrets) do %>
<div class="flex justify-between items-center text-gray-500">
<span class="text-sm break-all">
<%= secret_name %>
</span>
<.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}
/>
</div>
<% end %>
</div>
<%= 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

View file

@ -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
<h3 class="text-2xl font-semibold text-gray-800">
<%= @title %>
</h3>
<%= if @grant_access do %>
<.grant_access_message grant_access={@grant_access} target={@myself} />
<% end %>
<div class="flex flex-columns gap-4">
<%= if @select_secret_ref do %>
<div class="basis-1/2 grow-0 pr-4 border-r">
<div class="flex flex-col space-y-4">
<p class="text-gray-700">
<p class="text-gray-800">
Choose a secret
</p>
<div class="flex flex-wrap gap-4">
<%= 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 %>
</.choice_button>
<div class="flex flex-wrap">
<%= 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 %>
<div class="w-full text-center text-gray-400 border rounded-lg p-8">
<.remix_icon icon="folder-lock-line" class="align-middle text-2xl" />
<span class="mt-1 block text-sm text-gray-700">
@ -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"
) %>
</.input_wrapper>
@ -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"
) %>
</.input_wrapper>
<div>
<span class="text-base font-medium text-gray-900">Store</span>
<div class="mt-2 space-y-1">
<%= 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 %>
</div>
</div>
<div class="flex space-x-2">
<button class="button-base button-blue" type="submit" disabled={f.errors != []}>
<.remix_icon icon="add-line" class="align-middle" />
@ -110,13 +130,85 @@ defmodule LivebookWeb.SessionLive.SecretsComponent do
"""
end
defp secret_with_badge(assigns) do
~H"""
<div
role="button"
class={
if @active do
"flex justify-between w-full bg-blue-100 text-sm text-blue-700 p-2 border-b cursor-pointer"
else
"flex justify-between w-full text-sm text-gray-700 p-2 border-b cursor-pointer hover:bg-gray-100"
end
}
phx-value-secret_name={@secret_name}
phx-target={@target}
phx-click={@action}
>
<%= @secret_name %>
<span class={
if @active do
"inline-flex items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"
else
"inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800"
end
}>
<%= if @active do %>
<svg class="-ml-0.5 mr-1.5 h-2 w-2 text-blue-400" fill="currentColor" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="3" />
</svg>
<% end %>
<%= @stored %>
</span>
</div>
"""
end
defp grant_access_message(assigns) do
~H"""
<div>
<div class="mx-auto">
<div class="rounded-lg bg-blue-600 p-2 shadow-sm">
<div class="flex flex-wrap items-center justify-between">
<div class="flex w-0 flex-1 items-center">
<.remix_icon
icon="error-warning-fill"
class="align-middle text-2xl flex text-gray-100 rounded-lg py-2"
/>
<span class="ml-2 text-sm font-normal text-gray-100">
The secret <span class="font-semibold text-white"><%= @grant_access %></span>
needs to be made available to the session
</span>
</div>
<button
class="button-base button-gray"
phx-click="grant_access"
phx-value-secret_name={@grant_access}
phx-target={@target}
>
Grant access
</button>
</div>
</div>
</div>
</div>
"""
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

View file

@ -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

View file

@ -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