Implement users to join an organization (#1912)

This commit is contained in:
Alexandre de Souza 2023-05-23 17:18:10 -03:00 committed by GitHub
parent 7ff4b640f6
commit 070c0599b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 290 additions and 784 deletions

View file

@ -31,3 +31,6 @@ config :livebook, :feature_flags, create_hub: true
if System.get_env("CI") == "true" do
config :livebook, :node, {:longnames, :"livebook@127.0.0.1"}
end
# Teams
config :livebook, check_completion_data_interval: 300

View file

@ -18,10 +18,28 @@ defmodule Livebook.Teams do
| {:error, Ecto.Changeset.t()}
| {:transport_error, String.t()}
def create_org(%Org{} = org, attrs) do
create_org_request(org, attrs, &Client.create_org/1)
end
@doc """
Joins an Org.
With success, returns the response from Livebook Teams API to continue the org joining flow.
Otherwise, it will return an error tuple with changeset.
"""
@spec join_org(Org.t(), map()) ::
{:ok, map()}
| {:error, Ecto.Changeset.t()}
| {:transport_error, String.t()}
def join_org(%Org{} = org, attrs) do
create_org_request(org, attrs, &Client.join_org/1)
end
defp create_org_request(%Org{} = org, attrs, callback) when is_function(callback, 1) do
changeset = Org.changeset(org, attrs)
with {:ok, %Org{} = org} <- apply_action(changeset, :insert),
{:ok, response} <- Client.create_org(org) do
{:ok, response} <- callback.(org) do
{:ok, response}
else
{:error, %Ecto.Changeset{} = changeset} ->
@ -99,7 +117,7 @@ defmodule Livebook.Teams do
defp add_org_errors(%Ecto.Changeset{} = changeset, errors_map) do
for {key, errors} <- errors_map,
field <- String.to_atom(key),
field = String.to_atom(key),
field in Org.__schema__(:fields),
error <- errors,
reduce: changeset,

View file

@ -10,10 +10,16 @@ defmodule Livebook.Teams.Client do
@spec create_org(Org.t()) ::
{:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()}
def create_org(org) do
hash = :crypto.hash(:sha256, org.teams_key)
key_hash = Base.url_encode64(hash)
post("/api/org-request", %{name: org.name, key_hash: Org.key_hash(org)})
end
post("/api/org-request", %{name: org.name, key_hash: key_hash})
@doc """
Send a request to Livebook Team API to join an org.
"""
@spec join_org(Org.t()) ::
{:ok, map()} | {:error, map() | String.t()} | {:transport_error, String.t()}
def join_org(org) do
post("/api/org-request/join", %{name: org.name, key_hash: Org.key_hash(org)})
end
@doc """

View file

@ -29,17 +29,18 @@ defmodule Livebook.Teams.Org do
@spec teams_key() :: String.t()
def teams_key, do: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
@doc """
Generates a hash key.
"""
@spec key_hash(t()) :: String.t()
def key_hash(%__MODULE__{teams_key: teams_key}),
do: Base.url_encode64(:crypto.hash(:sha256, teams_key), padding: false)
@doc false
def changeset(org, attrs) do
org
|> cast(attrs, @fields)
|> generate_teams_key()
|> validate_required(@required_fields)
end
defp generate_teams_key(changeset) do
if get_field(changeset, :teams_key),
do: changeset,
else: put_change(changeset, :teams_key, teams_key())
|> validate_format(:name, ~r/^[a-z0-9][a-z0-9\-]*$/)
end
end

View file

@ -1,84 +0,0 @@
defmodule LivebookWeb.Hub.Edit.EnterpriseComponent do
use LivebookWeb, :live_component
alias Livebook.Hubs.Enterprise
alias LivebookWeb.LayoutHelpers
@impl true
def update(assigns, socket) do
changeset = Enterprise.change_hub(assigns.hub)
{:ok,
socket
|> assign(assigns)
|> assign(changeset: changeset)}
end
@impl true
def render(assigns) do
~H"""
<div id={"#{@id}-component"} class="space-y-8">
<div class="flex relative">
<LayoutHelpers.title text={"#{@hub.hub_emoji} #{@hub.hub_name}"} />
<button
phx-click={JS.push("delete_hub", value: %{id: @hub.id})}
class="absolute right-0 button-base button-red"
>
Delete hub
</button>
</div>
<div class="flex flex-col space-y-10">
<div class="flex flex-col space-y-2">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
General
</h2>
<.form
:let={f}
id={@id}
class="flex flex-col mt-4 space-y-4"
for={@changeset}
phx-submit="save"
phx-change="validate"
phx-target={@myself}
>
<div class="grid grid-cols-1 md:grid-cols-1 gap-3">
<.emoji_field field={f[:hub_emoji]} label="Emoji" />
</div>
<div>
<button
class="button-base button-blue"
type="submit"
phx-disable-with="Updating..."
disabled={not @changeset.valid?}
>
Update Hub
</button>
</div>
</.form>
</div>
</div>
</div>
"""
end
@impl true
def handle_event("save", %{"enterprise" => params}, socket) do
case Enterprise.update_hub(socket.assigns.hub, params) do
{:ok, hub} ->
{:noreply,
socket
|> put_flash(:success, "Hub updated successfully")
|> push_navigate(to: ~p"/hub/#{hub.id}")}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
def handle_event("validate", %{"enterprise" => attrs}, socket) do
{:noreply, assign(socket, changeset: Enterprise.validate_hub(socket.assigns.hub, attrs))}
end
end

View file

@ -1,212 +0,0 @@
defmodule LivebookWeb.Hub.Edit.FlyComponent do
use LivebookWeb, :live_component
alias Livebook.Hubs.{Fly, FlyClient}
alias LivebookWeb.LayoutHelpers
@impl true
def update(assigns, socket) do
changeset = Fly.change_hub(assigns.hub)
{:ok, app} = FlyClient.fetch_app(assigns.hub)
env_vars = env_vars_from_secrets(app["secrets"])
env_var =
if name = assigns.env_var_id do
Enum.find(env_vars, &(&1.name == name))
end
{:ok,
socket
|> assign(assigns)
|> assign(
app_url: "https://fly.io/apps/#{app["name"]}",
changeset: changeset,
env_vars: env_vars,
env_var: env_var
)}
end
defp env_vars_from_secrets(secrets) do
for secret <- secrets do
%Livebook.Settings.EnvVar{name: secret["name"]}
end
end
@impl true
def render(assigns) do
~H"""
<div id={"#{@id}-component"} class="space-y-8">
<div class="flex relative">
<LayoutHelpers.title text={"#{@hub.hub_emoji} #{@hub.hub_name}"} />
<button
phx-click={JS.push("delete_hub", value: %{id: @hub.id})}
class="absolute right-0 button-base button-red"
>
Delete hub
</button>
</div>
<div class="flex flex-col space-y-10">
<div class="flex flex-col space-y-2">
<div class="flex items-center justify-between border border-gray-200 rounded-lg p-4">
<div class="flex items-center space-x-12">
<.labeled_text label="Application ID">
<%= @hub.application_id %>
</.labeled_text>
<.labeled_text label="Type">
Fly
</.labeled_text>
</div>
<a href={@app_url} class="button-base button-outlined-gray" target="_blank">
<.remix_icon icon="dashboard-2-line" class="align-middle mr-1" />
<span>Manage app on Fly</span>
</a>
</div>
</div>
<div class="flex flex-col space-y-2">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
General
</h2>
<.form
:let={f}
id={@id}
class="flex flex-col mt-4 space-y-4"
for={@changeset}
phx-submit="save"
phx-change="validate"
phx-target={@myself}
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.text_field field={f[:hub_name]} label="Name" />
<.emoji_field field={f[:hub_emoji]} label="Emoji" />
</div>
<div>
<button
class="button-base button-blue"
phx-disable-with="Updating..."
disabled={not @changeset.valid?}
>
Update Hub
</button>
</div>
</.form>
</div>
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
Environment Variables
</h2>
<.live_component
module={LivebookWeb.EnvVarsComponent}
id="env-vars"
env_vars={@env_vars}
return_to={~p"/hub/#{@hub.id}"}
add_env_var_path={~p"/hub/#{@hub.id}/env-var/new"}
edit_label="Replace"
target={@myself}
/>
</div>
</div>
<.modal
:if={@live_action in [:add_env_var, :edit_env_var]}
id="env-var-modal"
show
width={:medium}
target={@myself}
patch={~p"/hub/#{@hub.id}"}
>
<.live_component
module={LivebookWeb.EnvVarComponent}
id="env-var"
on_save={JS.push("save_env_var", target: @myself)}
env_var={@env_var}
headline="Configute your Fly application system environment variables"
return_to={~p"/hub/#{@hub.id}"}
/>
</.modal>
</div>
"""
end
@impl true
def handle_event("save", %{"fly" => params}, socket) do
case Fly.update_hub(socket.assigns.hub, params) do
{:ok, hub} ->
{:noreply,
socket
|> put_flash(:success, "Hub updated successfully")
|> push_navigate(to: ~p"/hub/#{hub.id}")}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
def handle_event("validate", %{"fly" => attrs}, socket) do
{:noreply, assign(socket, changeset: Fly.validate_hub(socket.assigns.hub, attrs))}
end
# EnvVar component callbacks
def handle_event("save_env_var", %{"env_var" => attrs}, socket) do
env_operation = attrs["operation"]
attrs = %{"key" => attrs["name"], "value" => attrs["value"]}
case FlyClient.set_secrets(socket.assigns.hub, [attrs]) do
{:ok, _} ->
message =
if env_operation == "new",
do: "Environment variable added",
else: "Environment variable updated"
{:noreply,
socket
|> put_flash(:success, message)
|> push_navigate(to: ~p"/hub/#{socket.assigns.hub.id}")}
{:error, _} ->
message =
if env_operation == "new",
do: "Failed to add environment variable",
else: "Failed to update environment variable"
{:noreply,
socket
|> put_flash(:error, message)
|> push_navigate(to: ~p"/hub/#{socket.assigns.hub.id}")}
end
end
def handle_event("edit_env_var", %{"env_var" => key}, socket) do
{:noreply, push_patch(socket, to: ~p"/hub/#{socket.assigns.hub.id}/env-var/edit/#{key}")}
end
def handle_event("delete_env_var", %{"env_var" => key}, socket) do
on_confirm = fn socket ->
case FlyClient.unset_secrets(socket.assigns.hub, [key]) do
{:ok, _} ->
socket
|> put_flash(:success, "Environment variable deleted")
|> push_navigate(to: ~p"/hub/#{socket.assigns.hub.id}")
{:error, _} ->
socket
|> put_flash(:error, "Failed to delete environment variable")
|> push_navigate(to: ~p"/hub/#{socket.assigns.hub.id}")
end
end
{:noreply,
confirm(socket, on_confirm,
title: "Delete #{key}",
description: "Are you sure you want to delete environment variable?",
confirm_text: "Delete",
confirm_icon: "delete-bin-6-line"
)}
end
end

View file

@ -46,38 +46,35 @@ defmodule LivebookWeb.Hub.EditLive do
saved_hubs={@saved_hubs}
>
<div class="p-4 md:px-12 md:py-7 max-w-screen-md mx-auto">
<%= case @type do %>
<% "fly" -> %>
<.live_component
module={LivebookWeb.Hub.Edit.FlyComponent}
hub={@hub}
id="fly-form"
live_action={@live_action}
env_var_id={@env_var_id}
/>
<% "personal" -> %>
<.live_component
module={LivebookWeb.Hub.Edit.PersonalComponent}
hub={@hub}
secrets={@secrets}
live_action={@live_action}
secret_name={@secret_name}
id="personal-form"
/>
<% "enterprise" -> %>
<.live_component
module={LivebookWeb.Hub.Edit.EnterpriseComponent}
hub={@hub}
id="enterprise-form"
/>
<% "team" -> %>
<.live_component module={LivebookWeb.Hub.Edit.TeamComponent} hub={@hub} id="team-form" />
<% end %>
<.hub_component
type={@type}
hub={@hub}
live_action={@live_action}
secrets={@secrets}
secret_name={@secret_name}
/>
</div>
</LayoutHelpers.layout>
"""
end
defp hub_component(%{type: "personal"} = assigns) do
~H"""
<.live_component
module={LivebookWeb.Hub.Edit.PersonalComponent}
hub={@hub}
secrets={@secrets}
live_action={@live_action}
secret_name={@secret_name}
id="personal-form"
/>
"""
end
defp hub_component(%{type: "team"} = assigns) do
~H(<.live_component module={LivebookWeb.Hub.Edit.TeamComponent} hub={@hub} id="team-form" />)
end
@impl true
def handle_event("delete_hub", %{"id" => id}, socket) do
on_confirm = fn socket ->

View file

@ -8,7 +8,11 @@ defmodule LivebookWeb.Hub.NewLive do
on_mount LivebookWeb.SidebarHook
@check_completion_data_internal 3000
@check_completion_data_interval Application.compile_env(
:livebook,
:check_completion_data_interval,
3000
)
@impl true
def mount(_params, _session, socket) do
@ -22,7 +26,10 @@ defmodule LivebookWeb.Hub.NewLive do
requested_code: false,
org: nil,
verification_uri: nil,
org_form: nil
form: nil,
form_title: nil,
button_label: nil,
request_code_info: nil
)}
end
@ -85,85 +92,79 @@ defmodule LivebookWeb.Hub.NewLive do
</p>
</div>
<.org_form
form={@org_form}
org={@org}
requested_code={@requested_code}
selected={@selected_option}
verification_uri={@verification_uri}
/>
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
1. Select your option
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<.card_item id="new-org" selected={@selected_option} title="Create a new organization">
<:logo><.remix_icon icon="add-circle-fill" class="text-black text-3xl" /></:logo>
<:headline>Create a new organization and invite your team members.</:headline>
</.card_item>
<.card_item id="join-org" selected={@selected_option} title="Join an organization">
<:logo><.remix_icon icon="user-add-fill" class="text-black text-3xl" /></:logo>
<:headline>Join within the organization of your team members.</:headline>
</.card_item>
</div>
</div>
<div :if={@selected_option} class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
2. <%= @form_title %>
</h2>
<.form
:let={f}
id={"#{@selected_option}-form"}
class="flex flex-col space-y-4"
for={@form}
phx-submit="save"
phx-change="validate"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.text_field field={f[:name]} label="Name" />
<.emoji_field field={f[:emoji]} label="Emoji" />
</div>
<.password_field
readonly={@selected_option == "new-org"}
field={f[:teams_key]}
label="Livebook Teams Key"
/>
<div :if={@requested_code} class="grid grid-cols-1 gap-3">
<span><%= @request_code_info %></span>
<.link navigate={@verification_uri} target="_blank" class="font-bold text-blue-500">
<%= @verification_uri %>
</.link>
<span><%= @org.user_code %></span>
</div>
<button
:if={!@requested_code}
class="button-base button-blue"
phx-disable-with="Creating..."
>
<%= @button_label %>
</button>
</.form>
</div>
</div>
</LayoutHelpers.layout>
"""
end
defp org_form(assigns) do
~H"""
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
1. Select your option
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<.card_item id="new-org" selected={@selected} title="Create a new organization">
<:logo><.remix_icon icon="add-circle-fill" class="text-black text-3xl" /></:logo>
<:headline>Create a new organization and invite your team members.</:headline>
</.card_item>
<.card_item id="join-org" selected={@selected} disabled title="Join an organization">
<:logo><.remix_icon icon="user-add-fill" class="text-black text-3xl" /></:logo>
<:headline>Coming soon...</:headline>
</.card_item>
</div>
</div>
<div :if={@selected == "new-org"} class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-medium pb-2 border-b border-gray-200">
2. Create your Organization
</h2>
<.form
:let={f}
id="new-org-form"
class="flex flex-col space-y-4"
for={@form}
phx-submit="save"
phx-change="validate"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<.text_field field={f[:name]} label="Name" />
<.emoji_field field={f[:emoji]} label="Emoji" />
</div>
<.password_field readonly field={f[:teams_key]} label="Livebook Teams Key" />
<div :if={@requested_code} class="grid grid-cols-1 gap-3">
<span>
Access the following URL and input the User Code below to confirm the Organization creation.
</span>
<.link navigate={@verification_uri} target="_blank" class="font-bold text-blue-500">
<%= @verification_uri %>
</.link>
<span><%= @org.user_code %></span>
</div>
<button :if={!@requested_code} class="button-base button-blue" phx-disable-with="Creating...">
Create Org
</button>
</.form>
</div>
"""
end
defp card_item(assigns) do
assigns = assign_new(assigns, :disabled, fn -> false end)
~H"""
<div
id={@id}
class={["flex flex-col cursor-pointer", disabled_class(@disabled)]}
class="flex flex-col cursor-pointer"
phx-click={JS.push("select_option", value: %{value: @id})}
>
<div class={[
@ -185,9 +186,6 @@ defmodule LivebookWeb.Hub.NewLive do
"""
end
defp disabled_class(true), do: "opacity-30 pointer-events-none"
defp disabled_class(false), do: ""
defp card_item_border_class(id, id), do: "border-gray-200"
defp card_item_border_class(_, _), do: "border-gray-100"
@ -198,11 +196,11 @@ defmodule LivebookWeb.Hub.NewLive do
def handle_event("select_option", %{"value" => option}, socket) do
{:noreply,
socket
|> assign(selected_option: option)
|> assign(selected_option: option, requested_code: false, verification_uri: nil)
|> assign_form(option)}
end
def handle_event("validate", %{"new_org" => attrs}, socket) do
def handle_event("validate", %{"org" => attrs}, socket) do
changeset =
socket.assigns.org
|> Teams.change_org(attrs)
@ -211,14 +209,20 @@ defmodule LivebookWeb.Hub.NewLive do
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"new_org" => attrs}, socket) do
case Teams.create_org(socket.assigns.org, attrs) do
def handle_event("save", %{"org" => attrs}, socket) do
result =
case socket.assigns.selected_option do
"new-org" -> Teams.create_org(socket.assigns.org, attrs)
"join-org" -> Teams.join_org(socket.assigns.org, attrs)
end
case result do
{:ok, response} ->
attrs = Map.merge(attrs, response)
changeset = Teams.change_org(socket.assigns.org, attrs)
org = Ecto.Changeset.apply_action!(changeset, :insert)
Process.send_after(self(), :check_completion_data, @check_completion_data_internal)
Process.send_after(self(), :check_completion_data, @check_completion_data_interval)
{:noreply,
socket
@ -237,7 +241,7 @@ defmodule LivebookWeb.Hub.NewLive do
def handle_info(:check_completion_data, %{assigns: %{org: org}} = socket) do
case Teams.get_org_request_completion_data(org) do
{:ok, :awaiting_confirmation} ->
Process.send_after(self(), :check_completion_data, @check_completion_data_internal)
Process.send_after(self(), :check_completion_data, @check_completion_data_interval)
{:noreply, socket}
@ -264,10 +268,7 @@ defmodule LivebookWeb.Hub.NewLive do
{:noreply,
socket
|> assign(requested_code: false, org: org, verification_uri: nil)
|> put_flash(
:error,
"Oh no! Your org creation request expired, could you please try again?"
)
|> put_flash(:error, "Oh no! Your org request expired, could you please try again?")
|> assign_form(changeset)}
{:transport_error, message} ->
@ -277,16 +278,37 @@ defmodule LivebookWeb.Hub.NewLive do
def handle_info(_any, socket), do: {:noreply, socket}
defp assign_form(socket, "new-org") do
defp assign_form(socket, "join-org") do
org = %Org{emoji: "💡"}
changeset = Teams.change_org(org)
socket
|> assign(org: org)
|> assign(
org: org,
form_title: "Join an Organization",
button_label: "Join Org",
request_code_info:
"Access the following URL and input the User Code below to confirm the Organization creation."
)
|> assign_form(changeset)
end
defp assign_form(socket, "new-org") do
org = %Org{emoji: "💡", teams_key: Org.teams_key()}
changeset = Teams.change_org(org)
socket
|> assign(
org: org,
form_title: "Create your Organization",
button_label: "Create Org",
request_code_info:
"Access the following URL and input the User Code below to confirm to join an Organization."
)
|> assign_form(changeset)
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, org_form: to_form(changeset, as: :new_org))
assign(socket, form: to_form(changeset))
end
end

View file

@ -2,6 +2,7 @@ defmodule Livebook.TeamsTest do
use Livebook.TeamsIntegrationCase, async: true
alias Livebook.Teams
alias Livebook.Teams.Org
describe "create_org/1" do
test "returns the device flow data to confirm the org creation" do
@ -25,6 +26,41 @@ defmodule Livebook.TeamsTest do
end
end
describe "join_org/1" do
test "returns the device flow data to confirm the org creation", %{user: user, node: node} do
org = build(:org)
key_hash = Org.key_hash(org)
teams_org = :erpc.call(node, Hub.Integration, :create_org, [[name: org.name]])
:erpc.call(node, Hub.Integration, :create_org_key, [[org: teams_org, key_hash: key_hash]])
:erpc.call(node, Hub.Integration, :create_user_org, [[org: teams_org, user: user]])
assert {:ok,
%{
"device_code" => _device_code,
"expires_in" => 300,
"id" => _org_id,
"user_code" => _user_code,
"verification_uri" => _verification_uri
}} = Teams.join_org(org, %{})
end
test "returns changeset errors when data is invalid" do
org = build(:org)
assert {:error, changeset} = Teams.join_org(org, %{name: nil})
assert "can't be blank" in errors_on(changeset).name
end
test "returns changeset errors when org doesn't exist" do
org = build(:org)
assert {:error, changeset} = Teams.join_org(org, %{})
assert "does not exist" in errors_on(changeset).name
assert "does not match existing key" in errors_on(changeset).teams_key
end
end
describe "get_org_request_completion_data/1" do
test "returns the org data when it has been confirmed", %{node: node, user: user} do
teams_key = Teams.Org.teams_key()

View file

@ -6,243 +6,6 @@ defmodule LivebookWeb.Hub.EditLiveTest do
alias Livebook.Hubs
describe "fly" do
setup do
bypass = Bypass.open()
Application.put_env(:livebook, :fly_graphql_endpoint, "http://localhost:#{bypass.port}")
{:ok, bypass: bypass}
end
test "updates hub", %{conn: conn, bypass: bypass} do
{:ok, pid} = Agent.start(fn -> %{fun: &fetch_app_response/2, type: :mount} end)
app_id = Livebook.Utils.random_short_id()
hub = insert_hub(:fly, id: "fly-#{app_id}", application_id: app_id)
fly_bypass(bypass, app_id, pid)
{:ok, view, html} = live(conn, ~p"/hub/#{hub.id}")
assert html =~ "Manage app on Fly"
assert html =~ "https://fly.io/apps/#{hub.application_id}"
assert html =~ "Environment Variables"
refute html =~ "FOO_ENV_VAR"
assert html =~ "LIVEBOOK_PASSWORD"
assert html =~ "LIVEBOOK_SECRET_KEY_BASE"
attrs = %{
"hub_name" => "Personal Hub",
"hub_emoji" => "🐈"
}
view
|> element("#fly-form")
|> render_change(%{"fly" => attrs})
refute view
|> element("#fly-form .invalid-feedback")
|> has_element?()
assert {:ok, view, _html} =
view
|> element("#fly-form")
|> render_submit(%{"fly" => attrs})
|> follow_redirect(conn)
assert render(view) =~ "Hub updated successfully"
assert_hub(view, %{hub | hub_emoji: attrs["hub_emoji"], hub_name: attrs["hub_name"]})
refute Hubs.fetch_hub!(hub.id) == hub
end
test "deletes hub", %{conn: conn, bypass: bypass} do
{:ok, pid} = Agent.start(fn -> %{fun: &fetch_app_response/2, type: :mount} end)
app_id = Livebook.Utils.random_short_id()
hub_id = "fly-#{app_id}"
hub = insert_hub(:fly, id: hub_id, hub_name: "My Deletable Hub", application_id: app_id)
fly_bypass(bypass, app_id, pid)
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
view
|> element("button", "Delete hub")
|> render_click()
assert {:ok, view, _html} =
view
|> render_confirm()
|> follow_redirect(conn)
hubs_html = view |> element("#hubs") |> render()
refute hubs_html =~ ~p"/hub/#{hub.id}"
refute hubs_html =~ hub.hub_name
assert Hubs.fetch_hub(hub_id) == :error
end
test "add env var", %{conn: conn, bypass: bypass} do
{:ok, pid} = Agent.start(fn -> %{fun: &fetch_app_response/2, type: :mount} end)
app_id = Livebook.Utils.random_short_id()
hub = insert_hub(:fly, id: "fly-#{app_id}", application_id: app_id)
fly_bypass(bypass, app_id, pid)
{:ok, view, html} = live(conn, ~p"/hub/#{hub.id}")
assert html =~ "Manage app on Fly"
assert html =~ "https://fly.io/apps/#{hub.application_id}"
assert html =~ "Environment Variables"
refute html =~ "FOO_ENV_VAR"
assert html =~ "LIVEBOOK_PASSWORD"
assert html =~ "LIVEBOOK_SECRET_KEY_BASE"
view
|> element("#add-env-var")
|> render_click(%{})
assert_patch(view, ~p"/hub/#{hub.id}/env-var/new")
assert render(view) =~ "Add environment variable"
attrs = params_for(:env_var, name: "FOO_ENV_VAR")
view
|> element("#env-var-form")
|> render_change(%{"env_var" => attrs})
refute view
|> element("#env-var-form button[disabled]")
|> has_element?()
:ok = Agent.update(pid, fn state -> %{state | type: :add} end)
assert {:ok, _view, html} =
view
|> element("#env-var-form")
|> render_submit(%{"env_var" => attrs})
|> follow_redirect(conn)
assert html =~ "Environment variable added"
assert html =~ "Environment Variables"
assert html =~ "FOO_ENV_VAR"
assert html =~ "LIVEBOOK_PASSWORD"
assert html =~ "LIVEBOOK_SECRET_KEY_BASE"
end
test "update env var", %{conn: conn, bypass: bypass} do
{:ok, pid} = Agent.start(fn -> %{fun: &fetch_app_response/2, type: :foo} end)
app_id = Livebook.Utils.random_short_id()
hub = insert_hub(:fly, id: "fly-#{app_id}", application_id: app_id)
fly_bypass(bypass, app_id, pid)
{:ok, view, html} = live(conn, ~p"/hub/#{hub.id}")
assert html =~ "Manage app on Fly"
assert html =~ "https://fly.io/apps/#{hub.application_id}"
assert html =~ "Environment Variables"
assert html =~ "FOO_ENV_VAR"
view
|> element("#env-var-FOO_ENV_VAR-edit")
|> render_click(%{"env_var" => "FOO_ENV_VAR"})
assert_patch(view, ~p"/hub/#{hub.id}/env-var/edit/FOO_ENV_VAR")
assert render(view) =~ "Edit environment variable"
attrs = params_for(:env_var, name: "FOO_ENV_VAR")
view
|> element("#env-var-form")
|> render_change(%{"env_var" => attrs})
refute view
|> element("#env-var-form button[disabled]")
|> has_element?()
:ok = Agent.update(pid, fn state -> %{state | type: :updated_foo} end)
assert {:ok, _view, html} =
view
|> element("#env-var-form")
|> render_submit(%{"env_var" => attrs})
|> follow_redirect(conn)
assert html =~ "Environment variable updated"
assert html =~ "Environment Variables"
assert html =~ "FOO_ENV_VAR"
end
test "delete env var", %{conn: conn, bypass: bypass} do
{:ok, pid} = Agent.start(fn -> %{fun: &fetch_app_response/2, type: :add} end)
app_id = Livebook.Utils.random_short_id()
hub = insert_hub(:fly, id: "fly-#{app_id}", application_id: app_id)
fly_bypass(bypass, app_id, pid)
{:ok, view, html} = live(conn, ~p"/hub/#{hub.id}")
assert html =~ "Manage app on Fly"
assert html =~ "https://fly.io/apps/#{hub.application_id}"
assert html =~ "Environment Variables"
assert html =~ "FOO_ENV_VAR"
assert html =~ "LIVEBOOK_PASSWORD"
assert html =~ "LIVEBOOK_SECRET_KEY_BASE"
:ok = Agent.update(pid, fn state -> %{state | type: :mount} end)
view
|> element(~s/#env-var-FOO_ENV_VAR-delete/)
|> render_click()
assert {:ok, _view, html} =
view
|> render_confirm()
|> follow_redirect(conn)
assert html =~ "Environment variable deleted"
assert html =~ "Environment Variables"
refute html =~ "FOO_ENV_VAR"
assert html =~ "LIVEBOOK_PASSWORD"
assert html =~ "LIVEBOOK_SECRET_KEY_BASE"
end
end
describe "enterprise" do
test "updates hub", %{conn: conn} do
hub = insert_hub(:enterprise)
{:ok, view, _html} = live(conn, ~p"/hub/#{hub.id}")
attrs = %{"hub_emoji" => "🐈"}
view
|> element("#enterprise-form")
|> render_change(%{"enterprise" => attrs})
refute view
|> element("#enterprise-form .invalid-feedback")
|> has_element?()
assert {:ok, view, _html} =
view
|> element("#enterprise-form")
|> render_submit(%{"enterprise" => attrs})
|> follow_redirect(conn)
assert render(view) =~ "Hub updated successfully"
assert_hub(view, %{hub | hub_emoji: attrs["hub_emoji"]})
refute Hubs.fetch_hub!(hub.id) == hub
Hubs.delete_hub(hub.id)
end
end
describe "personal" do
setup do
Livebook.Hubs.subscribe([:secrets])
@ -382,114 +145,4 @@ defmodule LivebookWeb.Hub.EditLiveTest do
assert hubs_html =~ ~p"/hub/#{hub.id}"
assert hubs_html =~ hub.hub_name
end
defp fly_bypass(bypass, app_id, agent_pid) do
Bypass.expect(bypass, "POST", "/", fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
body = Jason.decode!(body)
response =
cond do
body["query"] =~ "setSecrets" ->
set_secrets_response()
body["query"] =~ "unsetSecrets" ->
unset_secrets_response()
true ->
Agent.get(agent_pid, fn
%{fun: fun, type: type} -> fun.(app_id, type)
%{fun: fun} -> fun.()
end)
end
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
end
defp fetch_app_response(app_id, type) do
app = %{
"id" => app_id,
"name" => app_id,
"hostname" => app_id <> ".fly.dev",
"platformVersion" => "nomad",
"deployed" => true,
"status" => "running",
"secrets" => secrets(type)
}
%{"data" => %{"app" => app}}
end
defp secrets(:mount) do
[
%{
"createdAt" => to_string(DateTime.utc_now()),
"digest" => to_string(Livebook.Utils.random_cookie()),
"id" => "123",
"name" => "LIVEBOOK_PASSWORD"
},
%{
"createdAt" => to_string(DateTime.utc_now()),
"digest" => to_string(Livebook.Utils.random_cookie()),
"id" => "456",
"name" => "LIVEBOOK_SECRET_KEY_BASE"
}
]
end
defp secrets(:add) do
[
%{
"createdAt" => to_string(DateTime.utc_now()),
"digest" => to_string(Livebook.Utils.random_cookie()),
"id" => "789",
"name" => "FOO_ENV_VAR"
},
%{
"createdAt" => to_string(DateTime.utc_now()),
"digest" => to_string(Livebook.Utils.random_cookie()),
"id" => "123",
"name" => "LIVEBOOK_PASSWORD"
},
%{
"createdAt" => to_string(DateTime.utc_now()),
"digest" => to_string(Livebook.Utils.random_cookie()),
"id" => "456",
"name" => "LIVEBOOK_SECRET_KEY_BASE"
}
]
end
defp secrets(:foo) do
[
%{
"createdAt" => "2022-08-31 14:47:39.904338Z",
"digest" => to_string(Livebook.Utils.random_cookie()),
"id" => "123456789",
"name" => "FOO_ENV_VAR"
}
]
end
defp secrets(:updated_foo) do
[
%{
"createdAt" => "2022-08-31 14:47:41.632669Z",
"digest" => to_string(Livebook.Utils.random_cookie()),
"id" => "123456789",
"name" => "FOO_ENV_VAR"
}
]
end
defp set_secrets_response do
%{"data" => %{"setSecrets" => %{"app" => %{"secrets" => secrets(:add)}}}}
end
defp unset_secrets_response do
%{"data" => %{"unsetSecrets" => %{"app" => %{"secrets" => secrets(:mount)}}}}
end
end

View file

@ -1,6 +1,8 @@
defmodule LivebookWeb.Hub.NewLiveTest do
use Livebook.TeamsIntegrationCase, async: true
alias Livebook.Teams.Org
import Phoenix.LiveViewTest
test "render hub selection cards", %{conn: conn} do
@ -13,11 +15,12 @@ defmodule LivebookWeb.Hub.NewLiveTest do
describe "new-org" do
test "persist a new hub", %{conn: conn, node: node, user: user} do
name = "New Org Test #{System.unique_integer([:positive])}"
name = "new-org-test"
teams_key = Livebook.Teams.Org.teams_key()
key_hash = Org.key_hash(build(:org, teams_key: teams_key))
path = ~p"/hub/team-#{name}"
{:ok, view, _html} = live(conn, ~p"/hub")
path = ~p"/hub/team-#{name}"
# select the new org option
assert view
@ -25,31 +28,33 @@ defmodule LivebookWeb.Hub.NewLiveTest do
|> render_click() =~ "2. Create your Organization"
# builds the form data
org_attrs = %{"new_org" => %{"name" => name, "teams_key" => teams_key, "emoji" => "🐈"}}
attrs = %{"org" => %{"name" => name, "teams_key" => teams_key, "emoji" => "🐈"}}
# finds the form and change data
new_org_form = element(view, "#new-org-form")
render_change(new_org_form, org_attrs)
form = element(view, "#new-org-form")
render_change(form, attrs)
# submits the form
render_submit(new_org_form, org_attrs)
# submits the form
render_submit(form, attrs)
# gets the org request by name and key hash
org_request =
:erpc.call(node, Hub.Integration, :get_org_request_by!, [
[name: name, key_hash: key_hash]
])
# check if the form has the url to confirm
link_element = element(view, "#new-org-form a")
html = render(link_element)
parsed_html = Floki.parse_document!(html)
assert [url] = Floki.attribute(parsed_html, "href")
assert [_port, [org_request_id]] = Regex.scan(~r/(?<=\D|^)\d{1,4}(?=\D|$)/, url)
id = String.to_integer(org_request_id)
assert render(link_element) =~ "/org-request/#{org_request.id}/confirm"
# force org request confirmation
org_request = :erpc.call(node, Hub.Integration, :get_org_request!, [id])
:erpc.call(node, Hub.Integration, :confirm_org_request, [org_request, user])
# wait for the c:handle_info/2 cycle
# check if the page redirected to edit hub page
# and check the flash message
%{"success" => "Hub added successfully"} = assert_redirect(view, path, 1200)
%{"success" => "Hub added successfully"} =
assert_redirect(view, path, check_completion_data_interval())
# checks if the hub is in the sidebar
{:ok, view, _html} = live(conn, path)
@ -59,4 +64,65 @@ defmodule LivebookWeb.Hub.NewLiveTest do
assert hubs_html =~ name
end
end
describe "join-org" do
test "persist a new hub", %{conn: conn, node: node, user: user} do
name = "join-org-test"
teams_key = Livebook.Teams.Org.teams_key()
key_hash = Org.key_hash(build(:org, teams_key: teams_key))
path = ~p"/hub/team-#{name}"
{:ok, view, _html} = live(conn, ~p"/hub")
# previously create the org and associate user with org
org = :erpc.call(node, Hub.Integration, :create_org, [[name: name]])
:erpc.call(node, Hub.Integration, :create_org_key, [[org: org, key_hash: key_hash]])
:erpc.call(node, Hub.Integration, :create_user_org, [[org: org, user: user]])
# select the new org option
assert view
|> element("#join-org")
|> render_click() =~ "2. Join an Organization"
# builds the form data
attrs = %{"org" => %{"name" => name, "teams_key" => teams_key, "emoji" => "🐈"}}
# finds the form and change data
form = element(view, "#join-org-form")
render_change(form, attrs)
# submits the form
render_submit(form, attrs)
# gets the org request by name and key hash
org_request =
:erpc.call(node, Hub.Integration, :get_org_request_by!, [
[name: name, key_hash: key_hash]
])
# check if the form has the url to confirm
link_element = element(view, "#join-org-form a")
assert render(link_element) =~ "/org-request/#{org_request.id}/confirm"
# force org request confirmation
:erpc.call(node, Hub.Integration, :confirm_org_request, [org_request, user])
# wait for the c:handle_info/2 cycle
# check if the page redirected to edit hub page
# and check the flash message
%{"success" => "Hub added successfully"} =
assert_redirect(view, path, check_completion_data_interval())
# checks if the hub is in the sidebar
{:ok, view, _html} = live(conn, path)
hubs_html = view |> element("#hubs") |> render()
assert hubs_html =~ "🐈"
assert hubs_html =~ path
assert hubs_html =~ name
end
end
defp check_completion_data_interval do
Application.fetch_env!(:livebook, :check_completion_data_interval) + 100
end
end

View file

@ -77,9 +77,9 @@ defmodule Livebook.Factory do
%Livebook.Teams.Org{
id: nil,
emoji: "🏭",
name: "Org Name #{System.unique_integer([:positive])}",
name: "org-name-#{System.unique_integer([:positive])}",
teams_key: Livebook.Teams.Org.teams_key(),
user_code: "request"
user_code: nil
}
end