diff --git a/lib/lightning/channels.ex b/lib/lightning/channels.ex index 7aeff4565b..4b3cb4d181 100644 --- a/lib/lightning/channels.ex +++ b/lib/lightning/channels.ex @@ -6,7 +6,12 @@ defmodule Lightning.Channels do import Ecto.Query + alias Ecto.Multi + alias Lightning.Accounts.User + alias Lightning.Channels.Audit alias Lightning.Channels.Channel + alias Lightning.Channels.ChannelAuthMethod + alias Lightning.Channels.ChannelRequest alias Lightning.Channels.ChannelSnapshot alias Lightning.Repo @@ -21,6 +26,53 @@ defmodule Lightning.Channels do |> Repo.all() end + @doc """ + Returns channels for a project with aggregate stats from channel_requests. + + Each entry is a map with keys: + - all Channel fields (via struct) + - `:request_count` — total number of requests + - `:last_activity` — datetime of most recent request, or nil + """ + def list_channels_for_project_with_stats(project_id) do + from(c in Channel, + where: c.project_id == ^project_id, + left_join: cr in ChannelRequest, + on: cr.channel_id == c.id, + group_by: c.id, + order_by: [asc: c.name], + select: %{ + channel: c, + request_count: count(cr.id), + last_activity: max(cr.started_at) + } + ) + |> Repo.all() + end + + @doc """ + Returns aggregate stats for all channels in a project. + + Returns a map with: + - `:total_channels` — number of channels in the project + - `:total_requests` — total channel requests across all channels + + Uses a single query with a LEFT JOIN so both counts are fetched in one + database round-trip. + """ + def get_channel_stats_for_project(project_id) do + from(c in Channel, + where: c.project_id == ^project_id, + left_join: cr in ChannelRequest, + on: cr.channel_id == c.id, + select: %{ + total_channels: count(c.id, :distinct), + total_requests: count(cr.id) + } + ) + |> Repo.one() + end + @doc """ Gets a single channel by ID. Returns nil if not found. """ @@ -55,26 +107,59 @@ defmodule Lightning.Channels do @doc """ Gets a single channel. Raises if not found. """ - def get_channel!(id) do - Repo.get!(Channel, id) + def get_channel!(id, opts \\ []) do + preloads = Keyword.get(opts, :include, []) + Repo.get!(Channel, id) |> Repo.preload(preloads) + end + + @doc """ + Gets a channel by ID scoped to a project. Returns `nil` if the channel + does not exist or belongs to a different project. + """ + def get_channel_for_project(project_id, channel_id) do + Repo.get_by(Channel, id: channel_id, project_id: project_id) end @doc """ Creates a channel. """ - def create_channel(attrs) do - %Channel{} - |> Channel.changeset(attrs) - |> Repo.insert() + @spec create_channel(map(), actor: User.t()) :: + {:ok, Channel.t()} | {:error, Ecto.Changeset.t()} + def create_channel(attrs, actor: %User{} = actor) do + changeset = Channel.changeset(%Channel{}, attrs) + + Multi.new() + |> Multi.insert(:channel, changeset) + |> Multi.insert(:audit, fn %{channel: channel} -> + Audit.event("created", channel.id, actor, changeset) + end) + |> maybe_audit_auth_method_changes(changeset, actor) + |> Repo.transaction() + |> case do + {:ok, %{channel: channel}} -> {:ok, channel} + {:error, :channel, changeset, _} -> {:error, changeset} + end end @doc """ Updates a channel's config fields, bumping lock_version. """ - def update_channel(%Channel{} = channel, attrs) do - channel - |> Channel.changeset(attrs) - |> Repo.update(stale_error_field: :lock_version) + @spec update_channel(Channel.t(), map(), actor: User.t()) :: + {:ok, Channel.t()} | {:error, Ecto.Changeset.t()} + def update_channel(%Channel{} = channel, attrs, actor: %User{} = actor) do + changeset = Channel.changeset(channel, attrs) + + Multi.new() + |> Multi.update(:channel, changeset, stale_error_field: :lock_version) + |> Multi.insert(:audit, fn %{channel: updated} -> + Audit.event("updated", updated.id, actor, changeset) + end) + |> maybe_audit_auth_method_changes(changeset, actor) + |> Repo.transaction() + |> case do + {:ok, %{channel: channel}} -> {:ok, channel} + {:error, :channel, changeset, _} -> {:error, changeset} + end end @doc """ @@ -83,14 +168,107 @@ defmodule Lightning.Channels do Returns `{:error, changeset}` if the channel has snapshots (due to `:restrict` FK on `channel_snapshots`). """ - def delete_channel(%Channel{} = channel) do - channel - |> Ecto.Changeset.change() - |> Ecto.Changeset.foreign_key_constraint(:channel_snapshots, - name: "channel_snapshots_channel_id_fkey", - message: "has history that must be retained" + @spec delete_channel(Channel.t(), actor: User.t()) :: + {:ok, Channel.t()} | {:error, Ecto.Changeset.t()} + def delete_channel(%Channel{} = channel, actor: %User{} = actor) do + changeset = + channel + |> Ecto.Changeset.change() + |> Ecto.Changeset.foreign_key_constraint(:channel_snapshots, + name: "channel_snapshots_channel_id_fkey", + message: "has history that must be retained" + ) + + Multi.new() + |> Multi.insert(:audit, Audit.event("deleted", channel.id, actor, %{})) + |> Multi.delete(:channel, changeset) + |> Repo.transaction() + |> case do + {:ok, %{channel: channel}} -> {:ok, channel} + {:error, :channel, changeset, _} -> {:error, changeset} + end + end + + # Emits one "auth_method_added" or "auth_method_removed" audit step per + # association change. No-op when the changeset has no auth method changes + # (e.g. the toggle handler, which passes no "channel_auth_methods" key). + defp maybe_audit_auth_method_changes(multi, changeset, actor) do + auth_changes = + Ecto.Changeset.get_change(changeset, :channel_auth_methods, []) + + inserted = Enum.filter(auth_changes, &(&1.action == :insert)) + deleted = Enum.filter(auth_changes, &(&1.action == :delete)) + + multi + |> add_auth_method_added_audits(inserted, actor) + |> add_auth_method_removed_audits(deleted, actor) + end + + defp add_auth_method_added_audits(multi, inserted, actor) do + inserted + |> Enum.with_index() + |> Enum.reduce(multi, fn {cs, idx}, acc -> + role = Ecto.Changeset.get_field(cs, :role) + fields = auth_method_fields_for(cs, role) + + Multi.insert( + acc, + :"audit_auth_method_added_#{idx}", + fn %{channel: channel} -> + Audit.event("auth_method_added", channel.id, actor, %{ + before: nil, + after: fields + }) + end + ) + end) + end + + defp add_auth_method_removed_audits(multi, deleted, actor) do + deleted + |> Enum.with_index() + |> Enum.reduce(multi, fn {cs, idx}, acc -> + role = cs.data.role + fields = auth_method_fields_for(cs, role) + + Multi.insert( + acc, + :"audit_auth_method_removed_#{idx}", + fn %{channel: channel} -> + Audit.event("auth_method_removed", channel.id, actor, %{ + before: fields, + after: nil + }) + end + ) + end) + end + + defp auth_method_fields_for(cs, :source) do + %{ + role: "source", + webhook_auth_method_id: + Ecto.Changeset.get_field(cs, :webhook_auth_method_id) + } + end + + defp auth_method_fields_for(cs, :sink) do + %{ + role: "sink", + project_credential_id: Ecto.Changeset.get_field(cs, :project_credential_id) + } + end + + @doc """ + Returns all ChannelAuthMethod records for a channel, preloading + their associated webhook_auth_method and project_credential (with credential). + """ + def list_channel_auth_methods(%Channel{} = channel) do + from(cam in ChannelAuthMethod, + where: cam.channel_id == ^channel.id, + preload: [:webhook_auth_method, project_credential: :credential] ) - |> Repo.delete() + |> Repo.all() end @doc """ diff --git a/lib/lightning/channels/audit.ex b/lib/lightning/channels/audit.ex new file mode 100644 index 0000000000..187463ea86 --- /dev/null +++ b/lib/lightning/channels/audit.ex @@ -0,0 +1,15 @@ +defmodule Lightning.Channels.Audit do + @moduledoc """ + Audit trail for channel CRUD operations. + """ + use Lightning.Auditing.Audit, + repo: Lightning.Repo, + item: "channel", + events: [ + "created", + "updated", + "deleted", + "auth_method_added", + "auth_method_removed" + ] +end diff --git a/lib/lightning/channels/channel.ex b/lib/lightning/channels/channel.ex index 7ec7b656e1..92b5f76cf1 100644 --- a/lib/lightning/channels/channel.ex +++ b/lib/lightning/channels/channel.ex @@ -54,5 +54,6 @@ defmodule Lightning.Channels.Channel do |> assoc_constraint(:project) |> unique_constraint([:project_id, :name]) |> optimistic_lock(:lock_version) + |> cast_assoc(:channel_auth_methods) end end diff --git a/lib/lightning/channels/channel_auth_method.ex b/lib/lightning/channels/channel_auth_method.ex index acb01d49dd..649805ecae 100644 --- a/lib/lightning/channels/channel_auth_method.ex +++ b/lib/lightning/channels/channel_auth_method.ex @@ -17,6 +17,7 @@ defmodule Lightning.Channels.ChannelAuthMethod do schema "channel_auth_methods" do field :role, Ecto.Enum, values: @roles + field :delete, :boolean, virtual: true belongs_to :channel, Channel belongs_to :webhook_auth_method, WebhookAuthMethod @@ -29,11 +30,11 @@ defmodule Lightning.Channels.ChannelAuthMethod do struct |> cast(attrs, [ :role, - :channel_id, :webhook_auth_method_id, - :project_credential_id + :project_credential_id, + :delete ]) - |> validate_required([:role, :channel_id]) + |> validate_required([:role]) |> Validators.validate_exclusive( [:webhook_auth_method_id, :project_credential_id], "webhook_auth_method_id and project_credential_id are mutually exclusive" @@ -52,6 +53,13 @@ defmodule Lightning.Channels.ChannelAuthMethod do |> unique_constraint(:project_credential_id, name: :channel_auth_methods_pc_unique ) + |> then(fn changeset -> + if get_change(changeset, :delete) do + %{changeset | action: :delete} + else + changeset + end + end) end defp validate_role_target_consistency(changeset) do diff --git a/lib/lightning/config/bootstrap.ex b/lib/lightning/config/bootstrap.ex index 11df8cadbe..6d822a8177 100644 --- a/lib/lightning/config/bootstrap.ex +++ b/lib/lightning/config/bootstrap.ex @@ -53,6 +53,29 @@ defmodule Lightning.Config.Bootstrap do if config_env() == :dev do enabled = env!("LIVE_DEBUGGER", &Utils.ensure_boolean/1, true) config :live_debugger, :disabled?, not enabled + + live_debugger_ip = + env!( + "LIVE_DEBUGGER_IP", + fn address -> + address + |> String.split(".") + |> Enum.map(&String.to_integer/1) + |> List.to_tuple() + end, + nil + ) + + if live_debugger_ip do + config :live_debugger, :ip, live_debugger_ip + end + + live_debugger_external_url = + env!("LIVE_DEBUGGER_EXTERNAL_URL", :string, nil) + + if live_debugger_external_url do + config :live_debugger, :external_url, live_debugger_external_url + end end # Load storage and webhook retry config early so endpoint can respect it. diff --git a/lib/lightning/policies/project_users.ex b/lib/lightning/policies/project_users.ex index 507cc20120..647092700d 100644 --- a/lib/lightning/policies/project_users.ex +++ b/lib/lightning/policies/project_users.ex @@ -28,6 +28,9 @@ defmodule Lightning.Policies.ProjectUsers do | :initiate_github_sync | :create_collection | :publish_template + | :create_channel + | :delete_channel + | :update_channel @doc """ authorize/3 takes an action, a user, and a project. It checks the user's role @@ -110,7 +113,10 @@ defmodule Lightning.Policies.ProjectUsers do :delete_workflow, :run_workflow, :create_project_credential, - :initiate_github_sync + :initiate_github_sync, + :create_channel, + :delete_channel, + :update_channel ] def authorize( diff --git a/lib/lightning/setup_utils.ex b/lib/lightning/setup_utils.ex index 86d57d498a..dea4ba789a 100644 --- a/lib/lightning/setup_utils.ex +++ b/lib/lightning/setup_utils.ex @@ -813,6 +813,9 @@ defmodule Lightning.SetupUtils do Lightning.Projects.File, Lightning.Projects.ProjectOauthClient, Lightning.Credentials.OauthClient, + Lightning.Channels.ChannelRequest, + Lightning.Channels.ChannelSnapshot, + Lightning.Channels.Channel, Lightning.Projects.Project, Lightning.Collaboration.DocumentState ]) diff --git a/lib/lightning_web/live/channel_live/form_component.ex b/lib/lightning_web/live/channel_live/form_component.ex new file mode 100644 index 0000000000..1639bb3d63 --- /dev/null +++ b/lib/lightning_web/live/channel_live/form_component.ex @@ -0,0 +1,276 @@ +defmodule LightningWeb.ChannelLive.FormComponent do + @moduledoc false + use LightningWeb, :live_component + + alias Lightning.Channels + alias Lightning.Channels.Channel + alias Lightning.Projects + alias Lightning.WebhookAuthMethods + + @impl true + def update( + %{channel: channel, project: project, on_close: _} = assigns, + socket + ) do + changeset = Channel.changeset(channel, %{}) + + wams = WebhookAuthMethods.list_for_project(project) + pcs = Projects.list_project_credentials(project) + + current_source_ids = + channel.channel_auth_methods + |> Enum.filter(&(&1.role == :source)) + |> Enum.map(& &1.webhook_auth_method_id) + + current_sink_ids = + channel.channel_auth_methods + |> Enum.filter(&(&1.role == :sink)) + |> Enum.map(& &1.project_credential_id) + + source_selections = + Map.new(wams, fn wam -> {wam.id, wam.id in current_source_ids} end) + + sink_selections = + Map.new(pcs, fn pc -> {pc.id, pc.id in current_sink_ids} end) + + {:ok, + socket + |> assign(assigns) + |> assign( + changeset: changeset, + webhook_auth_methods: wams, + project_credentials: pcs, + source_selections: source_selections, + sink_selections: sink_selections + )} + end + + @impl true + def handle_event("validate", %{"channel" => params}, socket) do + changeset = + socket.assigns.channel + |> Channel.changeset(params) + |> Map.put(:action, :validate) + + source_selections = + merge_selections( + socket.assigns.source_selections, + Map.get(params, "source_auth_methods", %{}) + ) + + sink_selections = + merge_selections( + socket.assigns.sink_selections, + Map.get(params, "sink_auth_methods", %{}) + ) + + {:noreply, + assign(socket, + changeset: changeset, + source_selections: source_selections, + sink_selections: sink_selections + )} + end + + def handle_event("save", %{"channel" => params}, socket) do + save_channel(socket, socket.assigns.action, params) + end + + @impl true + def render(assigns) do + assigns = + assign_new(assigns, :title, fn -> + case assigns.action do + :new -> "New Channel" + :edit -> "Edit Channel" + end + end) + + ~H""" +
+ <.modal show id={"#{@id}-modal"} width="w-full max-w-lg" on_close={@on_close}> + <:title> +
+ {@title} + +
+ + + <.form + :let={f} + for={to_form(@changeset)} + id={"channel-form-#{if @action == :edit, do: @channel.id, else: "new"}"} + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > +
+ <.input field={f[:name]} label="Name" type="text" phx-debounce="300" /> + + <.input + field={f[:sink_url]} + label="Sink URL" + type="text" + phx-debounce="300" + /> + + <.input field={f[:enabled]} label="Enabled" type="toggle" /> + +
+
+

+ Source Authentication Methods +

+ <.link + navigate={~p"/projects/#{@project}/settings#webhook_security"} + class="text-xs link" + > + Create a new one in project settings. + +
+
+ <.input + :for={wam <- @webhook_auth_methods} + id={"source_auth_#{wam.id}"} + name={"#{f.name}[source_auth_methods][#{wam.id}]"} + type="checkbox" + value={Map.get(@source_selections, wam.id, false)} + label={wam.name} + /> +
+
+ +
+
+

+ Sink Credentials +

+ <.link + navigate={~p"/projects/#{@project}/settings#credentials"} + class="text-xs link" + > + Create a new one in project settings. + +
+
+ <.input + :for={pc <- @project_credentials} + id={"sink_auth_#{pc.id}"} + name={"#{f.name}[sink_auth_methods][#{pc.id}]"} + type="checkbox" + value={Map.get(@sink_selections, pc.id, false)} + label={pc.credential.name} + /> +
+
+
+ + <.modal_footer> + <.button type="submit" theme="primary" phx-target={@myself}> + Save + + <.button theme="secondary" type="button" phx-click={@on_close}> + Cancel + + + + +
+ """ + end + + defp save_channel(socket, :new, params) do + params = + Map.merge(params, %{ + "project_id" => socket.assigns.project.id, + "channel_auth_methods" => build_auth_method_params(params, []) + }) + + case Channels.create_channel(params, actor: socket.assigns.current_user) do + {:ok, _channel} -> + {:noreply, + socket + |> put_flash(:info, "Channel created successfully.") + |> push_patch(to: ~p"/projects/#{socket.assigns.project}/channels")} + + {:error, changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end + + defp save_channel(socket, :edit, params) do + params = + Map.put( + params, + "channel_auth_methods", + build_auth_method_params( + params, + socket.assigns.channel.channel_auth_methods + ) + ) + + case Channels.update_channel( + socket.assigns.channel, + params, + actor: socket.assigns.current_user + ) do + {:ok, _channel} -> + {:noreply, + socket + |> put_flash(:info, "Channel updated successfully.") + |> push_patch(to: ~p"/projects/#{socket.assigns.project}/channels")} + + {:error, changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end + + defp merge_selections(current, submitted) do + Map.new(current, fn {k, _v} -> + {k, Map.get(submitted, k, "false") == "true"} + end) + end + + defp build_auth_method_params(params, current_auth_methods) do + build_auth_method_list(params, :source, current_auth_methods) ++ + build_auth_method_list(params, :sink, current_auth_methods) + end + + defp build_auth_method_list(params, role, current_auth_methods) do + id_field = auth_method_id_field(role) + + params + |> Map.get(auth_method_param_key(role), %{}) + |> Enum.reduce([], fn {k, v}, acc -> + existing_record = + Enum.find(current_auth_methods, &(Map.get(&1, id_field) == k)) + + case {existing_record, v} do + {%{}, "true"} -> [%{id: existing_record.id} | acc] + {nil, "true"} -> [%{id_field => k, role: to_string(role)} | acc] + {%{}, _} -> [%{id: existing_record.id, delete: true} | acc] + {nil, _} -> acc + end + end) + end + + defp auth_method_id_field(:source), do: :webhook_auth_method_id + defp auth_method_id_field(:sink), do: :project_credential_id + + defp auth_method_param_key(:source), do: "source_auth_methods" + defp auth_method_param_key(:sink), do: "sink_auth_methods" +end diff --git a/lib/lightning_web/live/channel_live/index.ex b/lib/lightning_web/live/channel_live/index.ex new file mode 100644 index 0000000000..91f759417b --- /dev/null +++ b/lib/lightning_web/live/channel_live/index.ex @@ -0,0 +1,363 @@ +defmodule LightningWeb.ChannelLive.Index do + @moduledoc false + use LightningWeb, :live_view + + alias Lightning.Channels + alias Lightning.Policies.Permissions + alias Lightning.Policies.ProjectUsers + alias LightningWeb.ChannelLive.FormComponent + alias LightningWeb.Components.Common + + on_mount {LightningWeb.Hooks, :project_scope} + on_mount {LightningWeb.Hooks, :check_limits} + + @impl true + def render(assigns) do + ~H""" + + <:banner> + + + <:header> + + <:breadcrumbs> + + + + <:label>{@page_title} + + + + + + + <.channel_metrics channel_stats={@channel_stats} /> +
+
+

+ Channels + ({length(@channels)}) +

+ <.link + :if={@can_create_channel} + patch={~p"/projects/#{@project}/channels/new"} + > + <.button id="new-channel-button" theme="primary"> + New Channel + + + <.button + :if={!@can_create_channel} + id="new-channel-button" + theme="primary" + disabled + tooltip="You are not authorized to perform this action." + > + New Channel + +
+ <.channels_table + id="channels-table" + channels={@channels} + can_edit_channel={@can_edit_channel} + can_delete_channel={@can_delete_channel} + project={@project} + /> +
+
+
+ <.live_component + :if={@live_action in [:new, :edit]} + module={FormComponent} + id={ + (@selected_channel && @selected_channel.id && + "edit-channel-#{@selected_channel.id}") || :new + } + action={@live_action} + channel={@selected_channel} + project={@project} + current_user={@current_user} + on_close={JS.patch(~p"/projects/#{@project}/channels")} + /> + """ + end + + @impl true + def mount(_params, _session, socket) do + %{current_user: current_user, project: project} = socket.assigns + + can_create_channel = + ProjectUsers + |> Permissions.can?(:create_channel, current_user, project) + + can_edit_channel = + ProjectUsers + |> Permissions.can?(:update_channel, current_user, project) + + can_delete_channel = + ProjectUsers + |> Permissions.can?(:delete_channel, current_user, project) + + {:ok, + socket + |> assign( + active_menu_item: :channels, + can_create_channel: can_create_channel, + can_edit_channel: can_edit_channel, + can_delete_channel: can_delete_channel, + channels: [], + channel_stats: %{total_channels: 0, total_requests: 0} + )} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :index, _params) do + project_id = socket.assigns.project.id + + socket + |> assign( + page_title: "Channels", + channels: Channels.list_channels_for_project_with_stats(project_id), + channel_stats: Channels.get_channel_stats_for_project(project_id), + selected_channel: nil + ) + end + + defp apply_action(socket, :new, _params) do + if socket.assigns.can_create_channel do + socket + |> assign( + page_title: "New Channel", + selected_channel: %Lightning.Channels.Channel{channel_auth_methods: []} + ) + else + socket + |> put_flash(:error, "You are not authorized to create channels.") + |> push_navigate(to: ~p"/projects/#{socket.assigns.project.id}/channels") + end + end + + defp apply_action(socket, :edit, %{"id" => id}) do + if socket.assigns.can_edit_channel do + channel = Channels.get_channel!(id, include: [:channel_auth_methods]) + + socket + |> assign( + page_title: "Edit Channel", + selected_channel: channel + ) + else + socket + |> put_flash(:error, "You are not authorized to edit channels.") + |> push_navigate(to: ~p"/projects/#{socket.assigns.project.id}/channels") + end + end + + @impl true + def handle_event( + "toggle_channel_state", + %{"channel_state" => enabled?, "value_key" => channel_id}, + socket + ) do + with :ok <- check_can_edit_channel(socket), + {:ok, channel} <- fetch_project_channel(socket, channel_id) do + case Channels.update_channel(channel, %{enabled: enabled?}, + actor: socket.assigns.current_user + ) do + {:ok, _channel} -> + socket + |> put_flash(:info, "Channel updated") + |> push_patch(to: ~p"/projects/#{socket.assigns.project.id}/channels") + |> noreply() + + {:error, _changeset} -> + socket + |> put_flash(:error, "Failed to update channel. Please try again.") + |> noreply() + end + end + end + + @impl true + def handle_event("delete_channel", %{"id" => id}, socket) do + with :ok <- check_can_delete_channel(socket), + {:ok, channel} <- fetch_project_channel(socket, id) do + case Channels.delete_channel(channel, actor: socket.assigns.current_user) do + {:ok, _} -> + socket + |> put_flash(:info, "Channel deleted.") + |> push_patch(to: ~p"/projects/#{socket.assigns.project.id}/channels") + |> noreply() + + {:error, changeset} -> + message = + if Keyword.has_key?(changeset.errors, :channel_snapshots), + do: + "Cannot delete \"#{channel.name}\" — it has request history that must be retained.", + else: "Failed to delete channel. Please try again." + + socket |> put_flash(:error, message) |> noreply() + end + end + end + + defp check_can_edit_channel(socket) do + if socket.assigns.can_edit_channel do + :ok + else + socket + |> put_flash(:error, "You are not authorized to perform this action.") + |> noreply() + end + end + + defp check_can_delete_channel(socket) do + if socket.assigns.can_delete_channel do + :ok + else + socket + |> put_flash(:error, "You are not authorized to perform this action.") + |> noreply() + end + end + + defp fetch_project_channel(socket, channel_id) do + case Channels.get_channel_for_project(socket.assigns.project.id, channel_id) do + %{} = channel -> {:ok, channel} + nil -> socket |> put_flash(:error, "Channel not found.") |> noreply() + end + end + + # --- Private components --- + + attr :channel_stats, :map, required: true + + defp channel_metrics(assigns) do + ~H""" +
+
+

Total Channels

+
+ {@channel_stats.total_channels} +
+
+
+

Total Requests

+
+ {@channel_stats.total_requests} +
+
+
+ """ + end + + attr :id, :string, required: true + attr :channels, :list, required: true + attr :project, :map, required: true + attr :can_edit_channel, :boolean, default: false + attr :can_delete_channel, :boolean, default: false + + defp channels_table(%{channels: []} = assigns) do + ~H""" +
+

No channels found.

+
+ """ + end + + defp channels_table(assigns) do + ~H""" + <.table id={@id}> + <:header> + <.tr> + <.th>Name + <.th>Sink URL + <.th>Requests + <.th>Last Activity + <.th>Enabled + <.th>Actions + + + <:body> + <%= for %{channel: channel, request_count: count, last_activity: last_at} <- + @channels do %> + <.tr id={"channel-#{channel.id}"}> + <.td class="wrap-break-word max-w-[15rem]"> + + {channel.name} + + + <.td class="wrap-break-word max-w-[20rem] text-sm text-gray-600 font-mono"> + {channel.sink_url} + + <.td class="text-gray-700"> + {count} + + <.td class="text-gray-500 text-sm"> + <%= if last_at do %> + + <% else %> + Never + <% end %> + + <.td> + <%= if @can_edit_channel do %> + <.input + id={channel.id} + type="toggle" + name="channel_state" + value={channel.enabled} + tooltip={unless channel.enabled, do: "#{channel.name} (disabled)"} + on_click="toggle_channel_state" + value_key={channel.id} + /> + <% else %> + + {if channel.enabled, do: "Enabled", else: "Disabled"} + + <% end %> + + <.td class="text-right"> +
+ <.link + :if={@can_edit_channel} + patch={~p"/projects/#{@project}/channels/#{channel.id}/edit"} + > + <.button theme="secondary" size="sm">Edit + + <.button + :if={@can_delete_channel} + theme="danger" + size="sm" + phx-click="delete_channel" + phx-value-id={channel.id} + data-confirm={"Delete \"#{channel.name}\"? This cannot be undone."} + > + Delete + +
+ + + <% end %> + + + """ + end +end diff --git a/lib/lightning_web/live/components/icon.ex b/lib/lightning_web/live/components/icon.ex index 5b5add0913..272fa32186 100644 --- a/lib/lightning_web/live/components/icon.ex +++ b/lib/lightning_web/live/components/icon.ex @@ -37,6 +37,8 @@ defmodule LightningWeb.Components.Icon do def sandboxes(assigns), do: Heroicons.beaker(assigns) + def channels(assigns), do: Heroicons.arrows_right_left(assigns) + def branches(assigns) do ~H""" <%= if Lightning.Accounts.experimental_features_enabled?(@current_user) do %> + <.menu_item + to={~p"/projects/#{@project_id}/channels"} + active={@active_menu_item == :channels} + > + + Channels + + <.menu_item to={~p"/projects/#{@project_id}/sandboxes"} active={@active_menu_item == :sandboxes} diff --git a/lib/lightning_web/router.ex b/lib/lightning_web/router.ex index 1b3ac33059..8088d652d0 100644 --- a/lib/lightning_web/router.ex +++ b/lib/lightning_web/router.ex @@ -244,6 +244,10 @@ defmodule LightningWeb.Router do live "/w/:id/legacy", WorkflowLive.Edit, :edit live "/w/:id", WorkflowLive.Collaborate, :edit + live "/channels", ChannelLive.Index, :index + live "/channels/new", ChannelLive.Index, :new + live "/channels/:id/edit", ChannelLive.Index, :edit + live "/sandboxes", SandboxLive.Index, :index live "/sandboxes/new", SandboxLive.Index, :new live "/sandboxes/:id/edit", SandboxLive.Index, :edit diff --git a/test/lightning/channels/channel_auth_method_test.exs b/test/lightning/channels/channel_auth_method_test.exs index b00b0cdc6d..48039787f0 100644 --- a/test/lightning/channels/channel_auth_method_test.exs +++ b/test/lightning/channels/channel_auth_method_test.exs @@ -102,11 +102,11 @@ defmodule Lightning.Channels.ChannelAuthMethodTest do assert msg =~ "sink auth must use a project credential" end - test "requires role and channel_id" do + test "requires role" do changeset = ChannelAuthMethod.changeset(%ChannelAuthMethod{}, %{}) refute changeset.valid? - assert %{role: _, channel_id: _} = errors_on(changeset) + assert %{role: _} = errors_on(changeset) end test "unique constraint on channel + role + webhook_auth_method_id" do @@ -120,10 +120,9 @@ defmodule Lightning.Channels.ChannelAuthMethodTest do ) assert {:error, changeset} = - %ChannelAuthMethod{} + %ChannelAuthMethod{channel_id: channel.id} |> ChannelAuthMethod.changeset(%{ role: :source, - channel_id: channel.id, webhook_auth_method_id: wam.id }) |> Lightning.Repo.insert() diff --git a/test/lightning/channels_test.exs b/test/lightning/channels_test.exs index c5345adc13..9a28187ceb 100644 --- a/test/lightning/channels_test.exs +++ b/test/lightning/channels_test.exs @@ -1,8 +1,13 @@ defmodule Lightning.ChannelsTest do use Lightning.DataCase, async: true + import Ecto.Query + + alias Lightning.Auditing.Audit alias Lightning.Channels alias Lightning.Channels.Channel + alias Lightning.Channels.ChannelAuthMethod + alias Lightning.Channels.ChannelRequest alias Lightning.Channels.ChannelSnapshot describe "list_channels_for_project/1" do @@ -24,6 +29,98 @@ defmodule Lightning.ChannelsTest do end end + describe "list_channels_for_project_with_stats/1" do + test "returns empty list for project with no channels" do + project = insert(:project) + + assert Channels.list_channels_for_project_with_stats(project.id) == [] + end + + test "returns correct request_count and last_activity for a channel with requests" do + project = insert(:project) + channel = insert(:channel, project: project) + {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel) + + t1 = ~U[2025-01-01 10:00:00.000000Z] + t2 = ~U[2025-01-02 12:00:00.000000Z] + + Lightning.Repo.insert!(%ChannelRequest{ + channel_id: channel.id, + channel_snapshot_id: snapshot.id, + request_id: "req-stats-1", + state: :success, + started_at: t1 + }) + + Lightning.Repo.insert!(%ChannelRequest{ + channel_id: channel.id, + channel_snapshot_id: snapshot.id, + request_id: "req-stats-2", + state: :success, + started_at: t2 + }) + + results = Channels.list_channels_for_project_with_stats(project.id) + + assert [ + %{ + channel: %Channel{id: channel_id}, + request_count: 2, + last_activity: ^t2 + } + ] = results + + assert channel_id == channel.id + end + + test "last_activity is nil when no requests exist" do + project = insert(:project) + insert(:channel, project: project) + + [result] = Channels.list_channels_for_project_with_stats(project.id) + + assert %{request_count: 0, last_activity: nil} = result + end + + test "returns multiple channels ordered by name with independent stats" do + project = insert(:project) + channel_b = insert(:channel, project: project, name: "bravo") + channel_a = insert(:channel, project: project, name: "alpha") + + {:ok, snapshot_b} = Channels.get_or_create_current_snapshot(channel_b) + + Lightning.Repo.insert!(%ChannelRequest{ + channel_id: channel_b.id, + channel_snapshot_id: snapshot_b.id, + request_id: "req-stats-3", + state: :success, + started_at: ~U[2025-06-01 00:00:00.000000Z] + }) + + results = Channels.list_channels_for_project_with_stats(project.id) + + assert [ + %{channel: %Channel{name: "alpha"}, request_count: 0}, + %{channel: %Channel{name: "bravo"}, request_count: 1} + ] = results + + assert Enum.find(results, &(&1.channel.id == channel_a.id)).last_activity == + nil + end + + test "excludes channels from other projects" do + project = insert(:project) + other_project = insert(:project) + insert(:channel, project: project, name: "mine") + insert(:channel, project: other_project, name: "theirs") + + results = Channels.list_channels_for_project_with_stats(project.id) + + assert length(results) == 1 + assert hd(results).channel.name == "mine" + end + end + describe "get_channel!/1" do test "returns the channel" do channel = insert(:channel) @@ -37,98 +134,396 @@ defmodule Lightning.ChannelsTest do end end - describe "create_channel/1" do - test "creates a channel with valid attrs" do + describe "create_channel/2" do + setup do + %{user: insert(:user)} + end + + test "emits 'auth_method_added' for each auth method on create", %{ + user: user + } do + project = insert(:project) + wam = insert(:webhook_auth_method, project: project) + + attrs = %{ + name: "my channel", + sink_url: "https://example.com", + project_id: project.id, + channel_auth_methods: [ + %{webhook_auth_method_id: wam.id, role: :source} + ] + } + + {:ok, channel} = Channels.create_channel(attrs, actor: user) + + events = + Repo.all( + from a in Audit, + where: a.item_id == ^channel.id and a.item_type == "channel" + ) + + added = Enum.filter(events, &(&1.event == "auth_method_added")) + + assert length(added) == 1 + + assert hd(added).changes.after == %{ + "role" => "source", + "webhook_auth_method_id" => wam.id + } + end + + test "does not emit auth method audit events when created with no auth methods", + %{user: user} do project = insert(:project) - assert {:ok, %Channel{} = channel} = - Channels.create_channel(%{ - name: "my-channel", - sink_url: "https://example.com/sink", - project_id: project.id - }) + attrs = %{ + name: "bare", + sink_url: "https://example.com", + project_id: project.id + } + + {:ok, channel} = Channels.create_channel(attrs, actor: user) + + events = + Repo.all( + from a in Audit, + where: a.item_id == ^channel.id and a.item_type == "channel" + ) - assert channel.name == "my-channel" - assert channel.enabled == true - assert channel.lock_version == 1 + refute Enum.any?( + events, + &(&1.event in ["auth_method_added", "auth_method_removed"]) + ) end - test "returns error on missing required fields" do - assert {:error, changeset} = Channels.create_channel(%{}) + test "creates a channel with valid attrs and records audit event", %{ + user: user + } do + project = insert(:project) + + assert {:ok, %Channel{} = channel} = + Channels.create_channel( + %{ + name: "my-channel", + sink_url: "https://example.com/sink", + project_id: project.id + }, + actor: user + ) + + assert %{ + name: "my-channel", + enabled: true, + lock_version: 1 + } = channel + + assert [audit] = + Repo.all( + from a in Audit, + where: a.item_id == ^channel.id and a.item_type == "channel" + ) + + assert %{event: "created", actor_id: actor_id} = audit + assert actor_id == user.id + end + + test "returns error on missing required fields", %{user: user} do + assert {:error, changeset} = + Channels.create_channel(%{}, actor: user) + assert %{name: _, sink_url: _, project_id: _} = errors_on(changeset) end - test "returns error for non-URL sink_url" do + test "returns error for non-URL sink_url", %{user: user} do project = insert(:project) assert {:error, changeset} = - Channels.create_channel(%{ - name: "bad-sink", - sink_url: "not a url", - project_id: project.id - }) + Channels.create_channel( + %{ + name: "bad-sink", + sink_url: "not a url", + project_id: project.id + }, + actor: user + ) assert %{sink_url: ["must be a valid URL"]} = errors_on(changeset) end - test "returns error for non-http scheme sink_url" do + test "returns error for non-http scheme sink_url", %{user: user} do project = insert(:project) assert {:error, changeset} = - Channels.create_channel(%{ - name: "ftp-sink", - sink_url: "ftp://example.com", - project_id: project.id - }) + Channels.create_channel( + %{ + name: "ftp-sink", + sink_url: "ftp://example.com", + project_id: project.id + }, + actor: user + ) assert %{sink_url: ["must be either a http or https URL"]} = errors_on(changeset) end - test "accepts valid http and https sink_urls" do + test "accepts valid http and https sink_urls", %{user: user} do project = insert(:project) assert {:ok, _} = - Channels.create_channel(%{ - name: "http-sink", - sink_url: "http://example.com/path", - project_id: project.id - }) + Channels.create_channel( + %{ + name: "http-sink", + sink_url: "http://example.com/path", + project_id: project.id + }, + actor: user + ) assert {:ok, _} = - Channels.create_channel(%{ - name: "https-sink", - sink_url: "https://example.com/path", - project_id: project.id - }) + Channels.create_channel( + %{ + name: "https-sink", + sink_url: "https://example.com/path", + project_id: project.id + }, + actor: user + ) end - test "returns error on duplicate name within project" do + test "returns error on duplicate name within project", %{user: user} do channel = insert(:channel) assert {:error, changeset} = - Channels.create_channel(%{ - name: channel.name, - sink_url: "https://example.com/other", - project_id: channel.project_id - }) + Channels.create_channel( + %{ + name: channel.name, + sink_url: "https://example.com/other", + project_id: channel.project_id + }, + actor: user + ) assert %{project_id: _} = errors_on(changeset) end end - describe "update_channel/2" do - test "updates config fields and bumps lock_version" do + describe "update_channel/3" do + setup do + %{user: insert(:user)} + end + + test "emits 'auth_method_added' for each added source auth method", %{ + user: user + } do + project = insert(:project) + wam = insert(:webhook_auth_method, project: project) + + channel = + insert(:channel, project: project) + |> Repo.preload(:channel_auth_methods) + + params = %{ + "channel_auth_methods" => [ + %{"webhook_auth_method_id" => wam.id, "role" => "source"} + ] + } + + {:ok, _} = Channels.update_channel(channel, params, actor: user) + + events = + Repo.all( + from a in Audit, + where: a.item_id == ^channel.id and a.item_type == "channel" + ) + + added = Enum.filter(events, &(&1.event == "auth_method_added")) + + assert length(added) == 1 + + assert %{ + changes: %{ + after: %{"role" => "source", "webhook_auth_method_id" => wam_id}, + before: nil + } + } = hd(added) + + assert wam_id == wam.id + end + + test "emits 'auth_method_added' for each added sink auth method", %{ + user: user + } do + project = insert(:project) + pc = insert(:project_credential, project: project) + + channel = + insert(:channel, project: project) + |> Repo.preload(:channel_auth_methods) + + params = %{ + "channel_auth_methods" => [ + %{"project_credential_id" => pc.id, "role" => "sink"} + ] + } + + {:ok, _} = Channels.update_channel(channel, params, actor: user) + + events = + Repo.all( + from a in Audit, + where: a.item_id == ^channel.id and a.item_type == "channel" + ) + + added = Enum.filter(events, &(&1.event == "auth_method_added")) + + assert length(added) == 1 + + assert %{ + changes: %{ + after: %{"role" => "sink", "project_credential_id" => pc_id}, + before: nil + } + } = hd(added) + + assert pc_id == pc.id + end + + test "emits 'auth_method_removed' for each removed source auth method", %{ + user: user + } do + project = insert(:project) + wam = insert(:webhook_auth_method, project: project) + channel = insert(:channel, project: project) + + cam = + insert(:channel_auth_method, + channel: channel, + webhook_auth_method: wam, + role: :source + ) + + channel = Repo.preload(channel, :channel_auth_methods) + + params = %{ + "channel_auth_methods" => [ + %{"id" => cam.id, "delete" => "true"} + ] + } + + {:ok, _} = Channels.update_channel(channel, params, actor: user) + + events = + Repo.all( + from a in Audit, + where: a.item_id == ^channel.id and a.item_type == "channel" + ) + + removed = Enum.filter(events, &(&1.event == "auth_method_removed")) + + assert length(removed) == 1 + + assert %{ + changes: %{ + before: %{ + "role" => "source", + "webhook_auth_method_id" => wam_id + }, + after: nil + } + } = hd(removed) + + assert wam_id == wam.id + end + + test "emits 'auth_method_removed' for each removed sink auth method", %{ + user: user + } do + project = insert(:project) + pc = insert(:project_credential, project: project) + channel = insert(:channel, project: project) + + cam = + insert(:channel_auth_method, + channel: channel, + webhook_auth_method: nil, + project_credential: pc, + role: :sink + ) + + channel = Repo.preload(channel, :channel_auth_methods) + + params = %{ + "channel_auth_methods" => [ + %{"id" => cam.id, "delete" => "true"} + ] + } + + {:ok, _} = Channels.update_channel(channel, params, actor: user) + + events = + Repo.all( + from a in Audit, + where: a.item_id == ^channel.id and a.item_type == "channel" + ) + + removed = Enum.filter(events, &(&1.event == "auth_method_removed")) + + assert length(removed) == 1 + + assert %{ + changes: %{ + before: %{"role" => "sink", "project_credential_id" => pc_id}, + after: nil + } + } = hd(removed) + + assert pc_id == pc.id + end + + test "does not emit auth method audit events when no auth methods change", %{ + user: user + } do + project = insert(:project) + channel = insert(:channel, project: project) + + {:ok, _} = + Channels.update_channel(channel, %{name: "new name"}, actor: user) + + events = + Repo.all( + from a in Audit, + where: a.item_id == ^channel.id and a.item_type == "channel" + ) + + refute Enum.any?( + events, + &(&1.event in ["auth_method_added", "auth_method_removed"]) + ) + end + + test "updates config fields, bumps lock_version, and records audit event", + %{user: user} do channel = insert(:channel) assert {:ok, updated} = - Channels.update_channel(channel, %{name: "new-name"}) + Channels.update_channel(channel, %{name: "new-name"}, actor: user) + + assert %{name: "new-name", lock_version: lock_version} = updated + assert lock_version == channel.lock_version + 1 - assert updated.name == "new-name" - assert updated.lock_version == channel.lock_version + 1 + assert [audit] = + Repo.all( + from a in Audit, + where: + a.item_id == ^channel.id and a.item_type == "channel" and + a.event == "updated" + ) + + assert audit.actor_id == user.id end - test "returns stale error on lock_version conflict" do + test "returns stale error on lock_version conflict", %{user: user} do channel = insert(:channel) # Simulate concurrent update by updating lock_version in DB @@ -139,27 +534,47 @@ defmodule Lightning.ChannelsTest do ) assert {:error, changeset} = - Channels.update_channel(channel, %{name: "stale-update"}) + Channels.update_channel(channel, %{name: "stale-update"}, + actor: user + ) assert changeset.errors[:lock_version] end end - describe "delete_channel/1" do - test "deletes a channel with no snapshots" do + describe "delete_channel/2" do + setup do + %{user: insert(:user)} + end + + test "deletes a channel with no snapshots and records audit event", %{ + user: user + } do channel = insert(:channel) - assert {:ok, %Channel{}} = Channels.delete_channel(channel) + channel_id = channel.id + + assert {:ok, %Channel{}} = Channels.delete_channel(channel, actor: user) assert_raise Ecto.NoResultsError, fn -> - Channels.get_channel!(channel.id) + Channels.get_channel!(channel_id) end + + assert [audit] = + Repo.all( + from a in Audit, + where: + a.item_id == ^channel_id and a.item_type == "channel" and + a.event == "deleted" + ) + + assert audit.actor_id == user.id end - test "returns error when channel has snapshots" do + test "returns error when channel has snapshots", %{user: user} do channel = insert(:channel) insert(:channel_snapshot, channel: channel) - assert {:error, changeset} = Channels.delete_channel(channel) + assert {:error, changeset} = Channels.delete_channel(channel, actor: user) assert %{channel_snapshots: _} = errors_on(changeset) end end @@ -209,7 +624,135 @@ defmodule Lightning.ChannelsTest do end end + describe "list_channel_auth_methods/1" do + test "returns empty list for a channel with no auth methods" do + channel = insert(:channel) + assert Channels.list_channel_auth_methods(channel) == [] + end + + test "returns preloaded source and sink records for a channel with both" do + project = insert(:project) + wam = insert(:webhook_auth_method, project: project) + pc = insert(:project_credential, project: project) + + channel = + insert(:channel, + project: project, + channel_auth_methods: [ + build(:channel_auth_method, + role: :source, + webhook_auth_method: wam + ), + build(:channel_auth_method, + role: :sink, + webhook_auth_method: nil, + project_credential: pc + ) + ] + ) + + cams = Channels.list_channel_auth_methods(channel) + + assert length(cams) == 2 + + source = Enum.find(cams, &(&1.role == :source)) + sink = Enum.find(cams, &(&1.role == :sink)) + + assert %ChannelAuthMethod{ + role: :source, + webhook_auth_method_id: wam_id, + webhook_auth_method: %{id: preloaded_wam_id} + } = source + + assert wam_id == wam.id + assert preloaded_wam_id == wam.id + + assert %ChannelAuthMethod{ + role: :sink, + project_credential_id: pc_id, + project_credential: %{id: preloaded_pc_id} + } = sink + + assert pc_id == pc.id + assert preloaded_pc_id == pc.id + end + end + + describe "get_channel_stats_for_project/1" do + test "returns zeros for a project with no channels" do + project = insert(:project) + + assert %{total_channels: 0, total_requests: 0} = + Channels.get_channel_stats_for_project(project.id) + end + + test "counts channels correctly" do + project = insert(:project) + insert(:channel, project: project) + insert(:channel, project: project) + + assert %{total_channels: 2} = + Channels.get_channel_stats_for_project(project.id) + end + + test "sums requests across all channels" do + project = insert(:project) + channel1 = insert(:channel, project: project) + channel2 = insert(:channel, project: project) + {:ok, snapshot1} = Channels.get_or_create_current_snapshot(channel1) + {:ok, snapshot2} = Channels.get_or_create_current_snapshot(channel2) + + Lightning.Repo.insert!(%ChannelRequest{ + channel_id: channel1.id, + channel_snapshot_id: snapshot1.id, + request_id: "stats-r1", + state: :success, + started_at: DateTime.utc_now() + }) + + Lightning.Repo.insert!(%ChannelRequest{ + channel_id: channel1.id, + channel_snapshot_id: snapshot1.id, + request_id: "stats-r2", + state: :success, + started_at: DateTime.utc_now() + }) + + Lightning.Repo.insert!(%ChannelRequest{ + channel_id: channel2.id, + channel_snapshot_id: snapshot2.id, + request_id: "stats-r3", + state: :success, + started_at: DateTime.utc_now() + }) + + assert %{total_requests: 3} = + Channels.get_channel_stats_for_project(project.id) + end + + test "does not count requests from other projects" do + project = insert(:project) + other_channel = insert(:channel) + {:ok, snapshot} = Channels.get_or_create_current_snapshot(other_channel) + + Lightning.Repo.insert!(%ChannelRequest{ + channel_id: other_channel.id, + channel_snapshot_id: snapshot.id, + request_id: "stats-other-r1", + state: :success, + started_at: DateTime.utc_now() + }) + + assert %{total_requests: 0} = + Channels.get_channel_stats_for_project(project.id) + end + end + describe "get_or_create_current_snapshot/1" do + setup do + %{user: insert(:user)} + end + test "creates snapshot on first call" do channel = insert(:channel) @@ -232,11 +775,13 @@ defmodule Lightning.ChannelsTest do assert snapshot1.id == snapshot2.id end - test "creates new snapshot on different lock_version" do + test "creates new snapshot on different lock_version", %{user: user} do channel = insert(:channel) {:ok, snapshot1} = Channels.get_or_create_current_snapshot(channel) - {:ok, updated} = Channels.update_channel(channel, %{name: "updated-name"}) + {:ok, updated} = + Channels.update_channel(channel, %{name: "updated-name"}, actor: user) + {:ok, snapshot2} = Channels.get_or_create_current_snapshot(updated) assert snapshot1.id != snapshot2.id diff --git a/test/lightning/config/bootstrap_test.exs b/test/lightning/config/bootstrap_test.exs index aef252cbdb..ec4ca192cf 100644 --- a/test/lightning/config/bootstrap_test.exs +++ b/test/lightning/config/bootstrap_test.exs @@ -747,6 +747,50 @@ defmodule Lightning.Config.BootstrapTest do end end + describe "live debugger (dev)" do + test "does not set :ip or :external_url by default" do + Dotenvy.source([%{}]) + Bootstrap.configure() + + assert get_env(:live_debugger, :ip) == nil + assert get_env(:live_debugger, :external_url) == nil + end + + test "LIVE_DEBUGGER_IP is parsed into a tuple" do + Dotenvy.source([%{"LIVE_DEBUGGER_IP" => "0.0.0.0"}]) + Bootstrap.configure() + + assert get_env(:live_debugger, :ip) == {0, 0, 0, 0} + end + + test "LIVE_DEBUGGER_EXTERNAL_URL is stored as a string" do + Dotenvy.source([ + %{"LIVE_DEBUGGER_EXTERNAL_URL" => "http://dev-elixir.local:4007"} + ]) + + Bootstrap.configure() + + assert get_env(:live_debugger, :external_url) == + "http://dev-elixir.local:4007" + end + + test "both env vars can be set together" do + Dotenvy.source([ + %{ + "LIVE_DEBUGGER_IP" => "0.0.0.0", + "LIVE_DEBUGGER_EXTERNAL_URL" => "http://dev-elixir.local:4007" + } + ]) + + Bootstrap.configure() + + assert get_env(:live_debugger, :ip) == {0, 0, 0, 0} + + assert get_env(:live_debugger, :external_url) == + "http://dev-elixir.local:4007" + end + end + # Helpers to read the in-process config that Config writes defp get_env(app) do Process.get(@config_key) diff --git a/test/lightning_web/live/channel_live/index_test.exs b/test/lightning_web/live/channel_live/index_test.exs new file mode 100644 index 0000000000..a3ae9f77b0 --- /dev/null +++ b/test/lightning_web/live/channel_live/index_test.exs @@ -0,0 +1,576 @@ +defmodule LightningWeb.ChannelLive.IndexTest do + use LightningWeb.ConnCase, async: true + + import Ecto.Query + import Phoenix.LiveViewTest + import Lightning.Factories + + alias Lightning.Auditing.Audit + alias Lightning.Channels + alias Lightning.Channels.ChannelRequest + alias Lightning.Repo + + setup :register_and_log_in_user + setup :create_project_for_current_user + + describe "access control" do + test "redirects unauthenticated users to login", %{project: project} do + conn = Phoenix.ConnTest.build_conn() + + assert {:error, {:redirect, %{to: "/users/log_in"}}} = + live(conn, ~p"/projects/#{project.id}/channels") + end + + test "renders the channels index for a project member", %{ + conn: conn, + project: project + } do + insert(:channel, project: project, name: "my-channel") + + {:ok, _view, html} = + live(conn, ~p"/projects/#{project.id}/channels") + + assert html =~ "Channels" + assert html =~ "my-channel" + end + + @tag role: :viewer + test "New Channel button is disabled for viewer role", %{ + conn: conn, + project: project + } do + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/channels") + + assert has_element?(view, "#new-channel-button:disabled") + end + + @tag role: :editor + test "New Channel button is enabled for editor role", %{ + conn: conn, + project: project + } do + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/channels") + + refute has_element?(view, "#new-channel-button:disabled") + assert has_element?(view, "#new-channel-button") + end + end + + describe "channel metrics" do + test "shows Total Channels and Total Requests stat cards", %{ + conn: conn, + project: project + } do + {:ok, _view, html} = live(conn, ~p"/projects/#{project.id}/channels") + + assert html =~ "Total Channels" + assert html =~ "Total Requests" + end + end + + describe "channel list rendering" do + test "shows empty state when project has no channels", %{ + conn: conn, + project: project + } do + {:ok, _view, html} = live(conn, ~p"/projects/#{project.id}/channels") + + assert html =~ "No channels found." + end + + test "shows channel columns with zero requests and Never for last activity", + %{ + conn: conn, + project: project + } do + channel = + insert(:channel, + project: project, + name: "test-channel", + sink_url: "https://sink.example.com/data" + ) + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/channels") + + assert has_element?(view, "tr#channel-#{channel.id}") + + html = render(view) + + assert html =~ "test-channel" + assert html =~ "https://sink.example.com/data" + assert html =~ "Never" + end + + test "shows request count and last activity when requests exist", %{ + conn: conn, + project: project + } do + channel = insert(:channel, project: project, name: "active-channel") + {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel) + t1 = ~U[2025-03-01 10:00:00.000000Z] + t2 = ~U[2025-03-05 14:30:00.000000Z] + + Repo.insert!(%ChannelRequest{ + channel_id: channel.id, + channel_snapshot_id: snapshot.id, + request_id: "req-lv-1", + state: :success, + started_at: t1 + }) + + Repo.insert!(%ChannelRequest{ + channel_id: channel.id, + channel_snapshot_id: snapshot.id, + request_id: "req-lv-2", + state: :success, + started_at: t2 + }) + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/channels") + + html = render(view) + + assert html =~ "active-channel" + assert html =~ "2" + refute html =~ "Never" + end + + test "does not show channels from other projects", %{ + conn: conn, + project: project + } do + insert(:channel, project: project, name: "mine") + other_project = insert(:project) + insert(:channel, project: other_project, name: "theirs") + + {:ok, _view, html} = live(conn, ~p"/projects/#{project.id}/channels") + + assert html =~ "mine" + refute html =~ "theirs" + end + end + + describe "toggle channel enabled state" do + @tag role: :editor + test "toggling a channel updates it, shows flash, and records audit event", + %{ + conn: conn, + project: project, + user: user + } do + channel = + insert(:channel, project: project, name: "toggle-me", enabled: true) + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/channels") + + assert has_element?(view, "tr#channel-#{channel.id}") + + html = + view + |> element("#toggle-control-#{channel.id}") + |> render_click() + + assert html =~ "Channel updated" + + updated = Channels.get_channel!(channel.id) + assert updated.enabled == false + + assert [audit] = + Repo.all( + from a in Audit, + where: + a.item_id == ^channel.id and a.item_type == "channel" and + a.event == "updated" + ) + + assert audit.actor_id == user.id + end + + @tag role: :editor + test "can re-enable a disabled channel", %{ + conn: conn, + project: project + } do + channel = + insert(:channel, project: project, name: "disabled-one", enabled: false) + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/channels") + + view + |> element("#toggle-control-#{channel.id}") + |> render_click() + + assert Channels.get_channel!(channel.id).enabled == true + end + end + + describe "new channel form" do + @tag role: :editor + test "navigating to /channels/new shows the form modal", %{ + conn: conn, + project: project + } do + {:ok, view, html} = + live(conn, ~p"/projects/#{project.id}/channels/new") + + assert html =~ "New Channel" + assert has_element?(view, "#new-modal") + end + + @tag role: :editor + test "submitting valid params creates channel and shows success flash", %{ + conn: conn, + project: project + } do + {:ok, view, _html} = + live(conn, ~p"/projects/#{project.id}/channels/new") + + form_id = "channel-form-new" + + view + |> form("##{form_id}", + channel: %{ + name: "new-channel", + sink_url: "https://example.com/sink" + } + ) + |> render_submit() + + assert_patch(view, ~p"/projects/#{project.id}/channels") + + html = render(view) + assert html =~ "Channel created successfully" + assert html =~ "new-channel" + + assert Channels.list_channels_for_project(project.id) + |> Enum.any?(&(&1.name == "new-channel")) + end + + @tag role: :editor + test "submitting with a source auth method saves the association", %{ + conn: conn, + project: project + } do + wam = insert(:webhook_auth_method, project: project) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project.id}/channels/new") + + view + |> form("#channel-form-new", + channel: %{ + name: "channel-with-auth", + sink_url: "https://example.com/sink", + source_auth_methods: %{wam.id => "true"} + } + ) + |> render_submit() + + assert_patch(view, ~p"/projects/#{project.id}/channels") + + channel = + Channels.list_channels_for_project(project.id) + |> Enum.find(&(&1.name == "channel-with-auth")) + + assert channel + + assert [cam] = Channels.list_channel_auth_methods(channel) + assert cam.role == :source + assert cam.webhook_auth_method_id == wam.id + end + + @tag role: :editor + test "submitting with missing name shows inline validation error", %{ + conn: conn, + project: project + } do + {:ok, view, _html} = + live(conn, ~p"/projects/#{project.id}/channels/new") + + form_id = "channel-form-new" + + html = + view + |> form("##{form_id}", channel: %{name: "", sink_url: ""}) + |> render_submit() + + assert html =~ "can't be blank" + end + + @tag role: :viewer + test "viewer is redirected when accessing /channels/new", %{ + conn: conn, + project: project + } do + assert {:error, {:live_redirect, %{to: redirect_to}}} = + live(conn, ~p"/projects/#{project.id}/channels/new") + + assert redirect_to == ~p"/projects/#{project.id}/channels" + end + end + + describe "edit channel form" do + @tag role: :editor + test "/channels/:id/edit mounts form pre-populated with channel values", %{ + conn: conn, + project: project + } do + channel = + insert(:channel, + project: project, + name: "edit-me", + sink_url: "https://old.example.com" + ) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project.id}/channels/#{channel.id}/edit") + + html = render(view) + assert html =~ "Edit Channel" + assert html =~ "edit-me" + assert html =~ "https://old.example.com" + end + + @tag role: :editor + test "saving valid changes updates the channel and shows success flash", %{ + conn: conn, + project: project + } do + channel = + insert(:channel, + project: project, + name: "old-name", + sink_url: "https://old.example.com" + ) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project.id}/channels/#{channel.id}/edit") + + form_id = "channel-form-#{channel.id}" + + view + |> form("##{form_id}", + channel: %{name: "updated-name", sink_url: "https://new.example.com"} + ) + |> render_submit() + + assert_patch(view, ~p"/projects/#{project.id}/channels") + + html = render(view) + assert html =~ "Channel updated successfully" + assert html =~ "updated-name" + + updated = Channels.get_channel!(channel.id) + assert updated.name == "updated-name" + end + + @tag role: :editor + test "shows settings links even when auth methods and credentials exist", + %{conn: conn, project: project, user: user} do + wam = insert(:webhook_auth_method, project: project) + credential = insert(:credential, project: project, user: user) + _pc = insert(:project_credential, project: project, credential: credential) + channel = insert(:channel, project: project) + + {:ok, _view, html} = + live(conn, ~p"/projects/#{project.id}/channels/#{channel.id}/edit") + + # Checkboxes for existing items are rendered + assert html =~ wam.name + assert html =~ credential.name + # Settings links are still present alongside the checkboxes + assert html =~ "/settings#webhook_security" + assert html =~ "/settings#credentials" + end + end + + describe "edit channel with existing auth methods" do + @tag role: :editor + test "pre-selects existing source and sink auth methods", %{ + conn: conn, + project: project, + user: user + } do + wam = insert(:webhook_auth_method, project: project) + credential = insert(:credential, project: project, user: user) + pc = insert(:project_credential, project: project, credential: credential) + + channel = insert(:channel, project: project) + + insert(:channel_auth_method, + channel: channel, + role: :source, + webhook_auth_method: wam + ) + + insert(:channel_auth_method, + channel: channel, + role: :sink, + webhook_auth_method: nil, + project_credential: pc + ) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project.id}/channels/#{channel.id}/edit") + + assert has_element?(view, "label", wam.name) + assert has_element?(view, "label", credential.name) + assert has_element?(view, "#source_auth_#{wam.id}[value='true']") + assert has_element?(view, "#sink_auth_#{pc.id}[value='true']") + end + + @tag role: :editor + test "saving can remove, keep, and add auth methods in a single save", %{ + conn: conn, + project: project, + user: user + } do + wam1 = insert(:webhook_auth_method, project: project) + wam2 = insert(:webhook_auth_method, project: project) + credential = insert(:credential, project: project, user: user) + pc = insert(:project_credential, project: project, credential: credential) + + channel = insert(:channel, project: project) + + insert(:channel_auth_method, + channel: channel, + role: :source, + webhook_auth_method: wam1 + ) + + insert(:channel_auth_method, + channel: channel, + role: :sink, + webhook_auth_method: nil, + project_credential: pc + ) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project.id}/channels/#{channel.id}/edit") + + # Remove wam1, keep pc (sink), add wam2 as a new source + view + |> form("#channel-form-#{channel.id}", + channel: %{ + name: channel.name, + source_auth_methods: %{wam1.id => "false", wam2.id => "true"}, + sink_auth_methods: %{pc.id => "true"} + } + ) + |> render_change() + + view + |> form("#channel-form-#{channel.id}") + |> render_submit() + + updated = + Channels.get_channel!(channel.id, include: [:channel_auth_methods]) + + source_cams = + Enum.filter(updated.channel_auth_methods, &(&1.role == :source)) + + sink_cams = Enum.filter(updated.channel_auth_methods, &(&1.role == :sink)) + + assert length(source_cams) == 1 + assert hd(source_cams).webhook_auth_method_id == wam2.id + + assert length(sink_cams) == 1 + assert hd(sink_cams).project_credential_id == pc.id + end + end + + describe "delete channel" do + @tag role: :editor + test "delete button uses data-confirm attribute (not phx-confirm)", %{ + conn: conn, + project: project + } do + _channel = insert(:channel, project: project, name: "confirm-me") + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/channels") + + assert has_element?( + view, + "[phx-click='delete_channel'][data-confirm]" + ) + + refute has_element?( + view, + "[phx-click='delete_channel'][phx-confirm]" + ) + end + + @tag role: :viewer + test "Edit and Delete buttons are absent for viewer role", %{ + conn: conn, + project: project + } do + channel = insert(:channel, project: project, name: "viewer-channel") + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/channels") + + refute has_element?( + view, + "[phx-click='delete_channel'][phx-value-id='#{channel.id}']" + ) + + refute has_element?( + view, + "a[patch$='/channels/#{channel.id}/edit']" + ) + end + + @tag role: :editor + test "deleting a channel removes it and shows success flash", %{ + conn: conn, + project: project + } do + channel = + insert(:channel, project: project, name: "delete-me") + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/channels") + + assert has_element?(view, "tr#channel-#{channel.id}") + + view + |> element("[phx-click='delete_channel'][phx-value-id='#{channel.id}']") + |> render_click() + + assert_patch(view, ~p"/projects/#{project.id}/channels") + + html = render(view) + assert html =~ "Channel deleted." + refute html =~ "delete-me" + + assert_raise Ecto.NoResultsError, fn -> + Channels.get_channel!(channel.id) + end + end + + @tag role: :editor + test "successful delete records audit event with actor", %{ + conn: conn, + project: project, + user: user + } do + channel = insert(:channel, project: project, name: "audit-delete") + channel_id = channel.id + + {:ok, view, _html} = live(conn, ~p"/projects/#{project.id}/channels") + + view + |> element("[phx-click='delete_channel'][phx-value-id='#{channel.id}']") + |> render_click() + + assert [audit] = + Repo.all( + from a in Audit, + where: + a.item_id == ^channel_id and a.item_type == "channel" and + a.event == "deleted" + ) + + assert audit.actor_id == user.id + end + end +end