From 402a46e1fa0e8d38f89cb65180fc0fb58328f289 Mon Sep 17 00:00:00 2001 From: Frank Midigo Date: Fri, 15 May 2026 10:24:35 +0300 Subject: [PATCH 1/3] Support channels in the provisioner API (#4522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/provision/:id and POST /api/provision now round-trip channels alongside workflows and collections. JSON exposes destination_credential_id; YAML uses a hyphenated email-credential-name key under destination_credential (matching how jobs reference credentials). Channel deletion is rejected with an actionable error directing the user to the dashboard. channel_requests have a RESTRICT FK that needs draining first, and the dashboard handles that transactionally — the provisioner shouldn't. Channel changes emit the same audits as Channels.create_channel/2 and update_channel/3 (created/updated plus auth_method_added/removed/changed for the destination credential). The provisioner runs a per-channel Multi with Multi.put(:channel, ...) so it reuses Audit.audit_auth_method_changes/3 unchanged. The :channels has_many was moved above :project_credentials on Project so Ecto's reverse-declaration child-processing order inserts project_credentials before channel_auth_methods, letting the destination FK resolve when both are created in the same import. --- CHANGELOG.md | 14 +- lib/lightning/channels/audit.ex | 3 + lib/lightning/channels/channel.ex | 27 +- lib/lightning/export_utils.ex | 49 +- lib/lightning/projects/project.ex | 3 +- lib/lightning/projects/provisioner.ex | 137 ++++++ .../controllers/api/provisioning_json.ex | 26 ++ test/fixtures/canonical_project.yaml | 1 + ...webhook_reply_and_cron_cursor_project.yaml | 1 + test/integration/cli_deploy_test.exs | 3 +- test/lightning/projects/provisioner_test.exs | 419 ++++++++++++++++++ test/lightning/projects_test.exs | 51 ++- .../api/provisioning_controller_test.exs | 66 +++ 13 files changed, 781 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de6c0bd6c31..3f1c76f3ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ and this project adheres to auth method and the destination project credential on every proxied request. Feature-gated behind experimental features. [#4541](https://github.com/OpenFn/lightning/issues/4541) +- Support channels in the provisioner API + [#4522](https://github.com/OpenFn/lightning/issues/4522) ### Changed @@ -43,13 +45,13 @@ and this project adheres to The sandbox's `project_users` are now derived from the parent project: every parent user is copied with their role preserved, the parent owner is demoted to `:admin`, and the actor is set as the sandbox owner. To add a user who is - not already on the parent, call `Lightning.Projects.add_project_users/3` - after `provision/3` returns. + not already on the parent, call `Lightning.Projects.add_project_users/3` after + `provision/3` returns. [#4744](https://github.com/OpenFn/lightning/issues/4744) -- `Lightning.Projects.delete_project_user!/1` now raises `ArgumentError` - when called with a project's `:owner` row. The settings UI already - prevented this; the guard closes the gap for Mix tasks, IEx, and - scripted callers that would otherwise have left a project ownerless. +- `Lightning.Projects.delete_project_user!/1` now raises `ArgumentError` when + called with a project's `:owner` row. The settings UI already prevented this; + the guard closes the gap for Mix tasks, IEx, and scripted callers that would + otherwise have left a project ownerless. - `./bin/bootstrap` on aarch64 Linux now requires Rust upfront and builds the Rambo native binary via `mix compile.rambo` post-compile, matching the darwin path. x86_64 Linux is unchanged. diff --git a/lib/lightning/channels/audit.ex b/lib/lightning/channels/audit.ex index 8f9bac8e538..7e974d57a53 100644 --- a/lib/lightning/channels/audit.ex +++ b/lib/lightning/channels/audit.ex @@ -26,6 +26,9 @@ defmodule Lightning.Channels.Audit do Inspects the changeset for changes to `:client_auth_methods` and `:destination_auth_method`, emitting the appropriate added/removed/changed events. No-op when no auth method changes are present. + + Expects the parent Multi to expose the channel under the `:channel` key + (e.g. via `Multi.insert(:channel, ...)` or `Multi.put(:channel, ...)`). """ def audit_auth_method_changes(multi, changeset, actor) do multi diff --git a/lib/lightning/channels/channel.ex b/lib/lightning/channels/channel.ex index 1b67a536088..ee6928be065 100644 --- a/lib/lightning/channels/channel.ex +++ b/lib/lightning/channels/channel.ex @@ -25,6 +25,7 @@ defmodule Lightning.Channels.Channel do field :destination_url, :string field :enabled, :boolean, default: true field :lock_version, :integer, default: 0 + field :delete, :boolean, virtual: true belongs_to :project, Project @@ -57,13 +58,7 @@ defmodule Lightning.Channels.Channel do :enabled ]) |> validate_required([:name, :destination_url, :project_id]) - |> Validators.validate_url(:destination_url) - |> assoc_constraint(:project) - |> unique_constraint([:project_id, :name], - error_key: :name, - message: "A channel with this name already exists in this project" - ) - |> optimistic_lock(:lock_version) + |> validate() |> cast_assoc(:client_auth_methods, with: fn struct, attrs -> ChannelAuthMethod.changeset(struct, put_role(attrs, "client")) @@ -76,6 +71,24 @@ defmodule Lightning.Channels.Channel do ) end + @doc """ + Shared validation rules for a channel changeset. + + Used by `changeset/2` and by callers (such as the provisioner) that + build a channel changeset with a different shape of input fields but + need the same business-rule validations. + """ + def validate(changeset) do + changeset + |> Validators.validate_url(:destination_url) + |> assoc_constraint(:project) + |> unique_constraint([:project_id, :name], + error_key: :name, + message: "A channel with this name already exists in this project" + ) + |> optimistic_lock(:lock_version) + end + defp put_role(attrs, role) when is_map(attrs) do key = if has_string_keys?(attrs), do: "role", else: :role Map.put(attrs, key, role) diff --git a/lib/lightning/export_utils.ex b/lib/lightning/export_utils.ex index b274485bbf7..fbc3d6c4a30 100644 --- a/lib/lightning/export_utils.ex +++ b/lib/lightning/export_utils.ex @@ -20,11 +20,13 @@ defmodule Lightning.ExportUtils do :name, :description, :collections, + :channels, :credentials, :globals, :workflows ], collection: [:name], + channel: [:name, :destination_url, :enabled, :destination_credential], credential: [:name, :owner], workflow: [:name, :jobs, :triggers, :edges], job: [:name, :adaptor, :credential, :globals, :body], @@ -360,13 +362,23 @@ defmodule Lightning.ExportUtils do Map.put(acc, hyphenate(collection.name), ytree) end) + channels_map = + project.channels + |> Enum.sort_by(& &1.inserted_at, NaiveDateTime) + |> Enum.reduce(%{}, fn channel, acc -> + ytree = build_channel_yaml_tree(channel, project.project_credentials) + + Map.put(acc, hyphenate(channel.name), ytree) + end) + %{ name: project.name, description: project.description, node_type: :project, workflows: workflows_map, credentials: credentials_map, - collections: collections_map + collections: collections_map, + channels: channels_map } end @@ -395,6 +407,35 @@ defmodule Lightning.ExportUtils do } end + defp build_channel_yaml_tree(channel, project_credentials) do + project_credential_id = channel_destination_project_credential_id(channel) + + project_credential = + project_credential_id && + Enum.find(project_credentials, fn pc -> + pc.id == project_credential_id + end) + + %{ + name: channel.name, + destination_url: channel.destination_url, + enabled: channel.enabled, + node_type: :channel, + destination_credential: + project_credential && project_credential_key(project_credential) + } + end + + defp channel_destination_project_credential_id(%{destination_auth_method: nil}), + do: nil + + defp channel_destination_project_credential_id(%{ + destination_auth_method: %{project_credential_id: id} + }), + do: id + + defp channel_destination_project_credential_id(_), do: nil + defp build_workflow_yaml_tree(workflow, project_credentials) do jobs = workflow.jobs @@ -427,7 +468,8 @@ defmodule Lightning.ExportUtils do project = Lightning.Repo.preload(project, project_credentials: [credential: :user], - collections: [] + collections: [], + channels: [destination_auth_method: :project_credential] ) yaml = @@ -443,7 +485,8 @@ defmodule Lightning.ExportUtils do project = Lightning.Repo.preload(project, project_credentials: [credential: :user], - collections: [] + collections: [], + channels: [destination_auth_method: :project_credential] ) yaml = diff --git a/lib/lightning/projects/project.ex b/lib/lightning/projects/project.ex index 062585fc161..2d8e481d7ea 100644 --- a/lib/lightning/projects/project.ex +++ b/lib/lightning/projects/project.ex @@ -48,11 +48,12 @@ defmodule Lightning.Projects.Project do has_many :workflows, Workflow, where: [deleted_at: nil] has_many :jobs, through: [:workflows, :jobs] + has_many :channels, Lightning.Channels.Channel + has_many :project_credentials, ProjectCredential has_many :credentials, through: [:project_credentials, :credential] has_many :collections, Lightning.Collections.Collection - has_many :channels, Lightning.Channels.Channel timestamps() end diff --git a/lib/lightning/projects/provisioner.ex b/lib/lightning/projects/provisioner.ex index 44f51cf1faa..e7daa0a6885 100644 --- a/lib/lightning/projects/provisioner.ex +++ b/lib/lightning/projects/provisioner.ex @@ -13,6 +13,9 @@ defmodule Lightning.Projects.Provisioner do alias Ecto.Multi alias Lightning.Accounts.User + alias Lightning.Channels.Audit, as: ChannelAudit + alias Lightning.Channels.Channel + alias Lightning.Channels.ChannelAuthMethod alias Lightning.Collections.Collection alias Lightning.Extensions.UsageLimiting.Action alias Lightning.Extensions.UsageLimiting.Context @@ -88,6 +91,8 @@ defmodule Lightning.Projects.Provisioner do updated_project <- preload_dependencies(project), {:ok, _changes} <- audit_workflows(project_changeset, user_or_repo_connection), + {:ok, _changes} <- + audit_channels(project_changeset, user_or_repo_connection), {:ok, _changes} <- update_workflows_version( project_changeset, @@ -211,6 +216,64 @@ defmodule Lightning.Projects.Provisioner do ) end + # Iterates each cast_assoc'd channel changeset, running a per-channel + # Multi for audits. Each Multi is independent so the static step names + # used by `Channels.Audit.audit_auth_method_changes/3` + # (`:audit_destination_added`, etc.) don't collide across channels. + # + # `Multi.put(:channel, %Channel{id: channel_id})` supplies the channel + # under the `:channel` key that the shared audit helper expects. + defp audit_channels(project_changeset, actor) do + project_changeset + |> get_assoc(:channels) + |> Enum.reduce_while({:ok, :done}, fn channel_cs, _acc -> + case audit_one_channel(channel_cs, actor) do + :skip -> {:cont, {:ok, :done}} + {:ok, _changes} -> {:cont, {:ok, :done}} + {:error, _, reason, _} -> {:halt, {:error, reason}} + end + end) + end + + defp audit_one_channel(channel_cs, actor) do + case classify_channel_audit(channel_cs) do + :skip -> + :skip + + {event, channel_id} -> + Multi.new() + |> Multi.put(:channel, %Channel{id: channel_id}) + |> append_channel_event_audit(event, channel_id, channel_cs, actor) + |> ChannelAudit.audit_auth_method_changes(channel_cs, actor) + |> Repo.transaction() + end + end + + defp classify_channel_audit(%{action: :insert} = cs) do + {"created", get_field(cs, :id)} + end + + defp classify_channel_audit(%{ + action: :update, + data: %{id: id}, + changes: changes + }) + when changes != %{} do + {"updated", id} + end + + defp classify_channel_audit(_), do: :skip + + defp append_channel_event_audit(multi, event, channel_id, channel_cs, actor) do + case ChannelAudit.event(event, channel_id, actor, channel_cs) do + :no_changes -> + multi + + %Ecto.Changeset{} = audit_cs -> + Multi.insert(multi, :channel_audit, audit_cs) + end + end + defp create_snapshots( project_changeset, inserted_workflows, @@ -255,6 +318,7 @@ defmodule Lightning.Projects.Provisioner do project |> project_changeset(data) |> cast_assoc(:collections, with: &collection_changeset/2) + |> cast_assoc(:channels, with: &channel_changeset/2) |> cast_assoc(:workflows, with: &workflow_changeset/2) |> then(fn changeset -> case WorkflowUsageLimiter.limit_workflows_activation( @@ -404,6 +468,7 @@ defmodule Lightning.Projects.Provisioner do [ :project_users, :collections, + channels: [destination_auth_method: :project_credential], project_credentials: [credential: [:user]], workflows: {w, [:jobs, :triggers, :edges]} ], @@ -434,6 +499,78 @@ defmodule Lightning.Projects.Provisioner do |> Collection.validate() end + defp channel_changeset(channel, attrs) do + attrs = maybe_add_destination_auth_param(attrs, channel) + + channel + |> cast(attrs, [:id, :name, :destination_url, :enabled, :delete]) + |> validate_required([:id, :name, :destination_url]) + |> block_channel_deletion() + |> cast_assoc(:destination_auth_method, + with: &ChannelAuthMethod.changeset/2 + ) + |> validate_extraneous_params() + |> Channel.validate() + end + + # Channel deletion is intentionally rejected by the provisioner because + # `channel_requests` rows must be drained first (the `on_delete: :restrict` + # FK). Users should delete channels through the dashboard, which handles + # request cleanup transactionally. + defp block_channel_deletion(changeset) do + if get_change(changeset, :delete) == true do + add_error( + changeset, + :delete, + "channel deletion is not supported via the provisioning API; " <> + "delete from the dashboard instead" + ) + else + changeset + end + end + + defp maybe_add_destination_auth_param(attrs, %Channel{} = channel) do + case Map.fetch(attrs, "destination_credential_id") do + :error -> + attrs + + {:ok, project_credential_id} -> + attrs + |> Map.delete("destination_credential_id") + |> Map.put( + "destination_auth_method", + build_destination_auth_method_attrs( + existing_destination_auth_method(channel), + project_credential_id + ) + ) + end + end + + defp existing_destination_auth_method(%Channel{ + destination_auth_method: %ChannelAuthMethod{} = method + }), + do: method + + defp existing_destination_auth_method(_), do: nil + + defp build_destination_auth_method_attrs(current, project_credential_id) do + case {current, project_credential_id} do + {_, nil} -> + nil + + {%{id: id, project_credential_id: pc_id}, pc_id} -> + %{"id" => id} + + {_, _} -> + %{ + "role" => "destination", + "project_credential_id" => project_credential_id + } + end + end + defp workflow_changeset(workflow, attrs) do workflow |> cast(attrs, [:id, :name, :delete, :deleted_at]) diff --git a/lib/lightning_web/controllers/api/provisioning_json.ex b/lib/lightning_web/controllers/api/provisioning_json.ex index 3db39601d1c..e3585aa72f7 100644 --- a/lib/lightning_web/controllers/api/provisioning_json.ex +++ b/lib/lightning_web/controllers/api/provisioning_json.ex @@ -4,6 +4,8 @@ defmodule LightningWeb.API.ProvisioningJSON do import LightningWeb.CoreComponents, only: [translate_error: 1] import Ecto.Changeset + alias Lightning.Channels.Channel + alias Lightning.Channels.ChannelAuthMethod alias Lightning.Collections.Collection alias Lightning.Projects.Project alias Lightning.Projects.ProjectCredential @@ -42,6 +44,12 @@ defmodule LightningWeb.API.ProvisioningJSON do |> Enum.sort_by(& &1.inserted_at, NaiveDateTime) |> Enum.map(&as_json/1) ) + |> Map.put( + :channels, + project.channels + |> Enum.sort_by(& &1.inserted_at, NaiveDateTime) + |> Enum.map(&as_json/1) + ) end def as_json(%module{} = workflow_or_snapshot) @@ -131,6 +139,24 @@ defmodule LightningWeb.API.ProvisioningJSON do %{id: collection.id, name: collection.name} end + def as_json(%Channel{} = channel) do + %{ + id: channel.id, + name: channel.name, + destination_url: channel.destination_url, + enabled: channel.enabled, + destination_credential_id: destination_credential_id(channel) + } + |> drop_keys_with_nil_value() + end + + defp destination_credential_id(%Channel{destination_auth_method: method}) do + case method do + %ChannelAuthMethod{project_credential_id: id} -> id + _ -> nil + end + end + defp drop_keys_with_nil_value(map) do Map.reject(map, fn {_, v} -> is_nil(v) end) end diff --git a/test/fixtures/canonical_project.yaml b/test/fixtures/canonical_project.yaml index 9c6efd61945..da678413ce8 100644 --- a/test/fixtures/canonical_project.yaml +++ b/test/fixtures/canonical_project.yaml @@ -4,6 +4,7 @@ description: | collections: cannonical-collection: name: cannonical-collection +channels: null credentials: cannonical-user@lightning.com-new-credential: name: new credential diff --git a/test/fixtures/webhook_reply_and_cron_cursor_project.yaml b/test/fixtures/webhook_reply_and_cron_cursor_project.yaml index 90f2d6e027c..2d393f84888 100644 --- a/test/fixtures/webhook_reply_and_cron_cursor_project.yaml +++ b/test/fixtures/webhook_reply_and_cron_cursor_project.yaml @@ -1,6 +1,7 @@ name: webhook-reply-and-cron-cursor-project description: null collections: null +channels: null credentials: null workflows: cron-cursor-workflow: diff --git a/test/integration/cli_deploy_test.exs b/test/integration/cli_deploy_test.exs index f15b3404260..85f19aac8ce 100644 --- a/test/integration/cli_deploy_test.exs +++ b/test/integration/cli_deploy_test.exs @@ -355,7 +355,8 @@ defmodule Lightning.CliDeployTest do Map.merge(state, %{ workflows: workflows, project_credentials: credentials, - collections: collections + collections: collections, + channels: [] }) end diff --git a/test/lightning/projects/provisioner_test.exs b/test/lightning/projects/provisioner_test.exs index bf1f31d9cee..3738c92cd28 100644 --- a/test/lightning/projects/provisioner_test.exs +++ b/test/lightning/projects/provisioner_test.exs @@ -324,6 +324,102 @@ defmodule Lightning.Projects.ProvisionerTest do } = collection end + test "creates channels" do + Mox.verify_on_exit!() + user = insert(:user) + + %{body: body, project_id: project_id} = valid_document() + + channel_id = Ecto.UUID.generate() + + body_with_channels = + Map.put(body, "channels", [ + %{ + id: channel_id, + name: "my-channel", + destination_url: "https://example.com/destination", + enabled: true + } + ]) + + Mox.stub( + Lightning.Extensions.MockUsageLimiter, + :limit_action, + fn _action, _context -> :ok end + ) + + {:ok, project} = + Provisioner.import_document( + %Lightning.Projects.Project{}, + user, + body_with_channels + ) + + assert %{id: ^project_id, channels: [channel]} = project + + assert %{ + id: ^channel_id, + name: "my-channel", + destination_url: "https://example.com/destination", + enabled: true, + project_id: ^project_id + } = channel + end + + test "creates a channel with a destination_credential_id" do + Mox.verify_on_exit!() + user = insert(:user) + + credential = insert(:credential, name: "Dest Cred", user: user) + + %{body: body, project_id: project_id} = valid_document() + + project_credential_id = Ecto.UUID.generate() + channel_id = Ecto.UUID.generate() + + credentials_payload = [ + %{ + "id" => project_credential_id, + "name" => credential.name, + "owner" => user.email + } + ] + + body_with_channels = + body + |> Map.put("project_credentials", credentials_payload) + |> Map.put("channels", [ + %{ + "id" => channel_id, + "name" => "my-channel", + "destination_url" => "https://example.com/destination", + "enabled" => true, + "destination_credential_id" => project_credential_id + } + ]) + + Mox.stub( + Lightning.Extensions.MockUsageLimiter, + :limit_action, + fn _action, _context -> :ok end + ) + + {:ok, project} = + Provisioner.import_document( + %Lightning.Projects.Project{}, + user, + body_with_channels + ) + + assert %{id: ^project_id, channels: [channel]} = project + assert channel.id == channel_id + + assert %Lightning.Channels.ChannelAuthMethod{ + role: :destination, + project_credential_id: ^project_credential_id + } = channel.destination_auth_method + end + test "imports trigger with webhook_reply field" do Mox.verify_on_exit!() user = insert(:user) @@ -1102,6 +1198,329 @@ defmodule Lightning.Projects.ProvisionerTest do assert remaining_collection.id == collection.id end + test "updating a channel", %{ + project: %{id: project_id} = project, + user: user + } do + channel = insert(:channel, project: project, name: "old-name") + channel_id = channel.id + + body = %{ + "id" => project_id, + "name" => "test-project", + "channels" => [ + %{ + "id" => channel_id, + "name" => "new-name", + "destination_url" => "https://example.com/new", + "enabled" => false + } + ] + } + + assert {:ok, %{id: ^project_id, channels: [updated]}} = + Provisioner.import_document(project, user, body) + + assert %{ + id: ^channel_id, + name: "new-name", + destination_url: "https://example.com/new", + enabled: false + } = updated + end + + test "audits channel create, update, and destination auth changes", %{ + project: %{id: project_id} = project, + user: %{id: user_id} = user + } do + pc1 = insert(:project_credential, project: project) + pc2 = insert(:project_credential, project: project) + + # 1. Create a channel with a destination credential + new_channel_id = Ecto.UUID.generate() + + body_create = %{ + "id" => project_id, + "name" => "test-project", + "channels" => [ + %{ + "id" => new_channel_id, + "name" => "audit-channel", + "destination_url" => "https://example.com/destination", + "enabled" => true, + "destination_credential_id" => pc1.id + } + ] + } + + assert {:ok, _project} = + Provisioner.import_document(project, user, body_create) + + assert created_audit = + Repo.one( + from a in Audit, + where: + a.item_id == ^new_channel_id and a.item_type == "channel" and + a.event == "created" + ) + + assert created_audit.actor_id == user_id + + assert auth_added_audit = + Repo.one( + from a in Audit, + where: + a.item_id == ^new_channel_id and a.item_type == "channel" and + a.event == "auth_method_added" + ) + + assert auth_added_audit.actor_id == user_id + + # 2. Update the channel and swap the credential + body_update = %{ + "id" => project_id, + "name" => "test-project", + "channels" => [ + %{ + "id" => new_channel_id, + "name" => "audit-channel-renamed", + "destination_credential_id" => pc2.id + } + ] + } + + assert {:ok, _project} = + Provisioner.import_document(project, user, body_update) + + assert Repo.one( + from a in Audit, + where: + a.item_id == ^new_channel_id and a.item_type == "channel" and + a.event == "updated" + ) + + assert Repo.one( + from a in Audit, + where: + a.item_id == ^new_channel_id and a.item_type == "channel" and + a.event == "auth_method_changed" + ) + + # 3. Clear the credential + body_clear = %{ + "id" => project_id, + "name" => "test-project", + "channels" => [ + %{ + "id" => new_channel_id, + "destination_credential_id" => nil + } + ] + } + + assert {:ok, _project} = + Provisioner.import_document(project, user, body_clear) + + assert Repo.one( + from a in Audit, + where: + a.item_id == ^new_channel_id and a.item_type == "channel" and + a.event == "auth_method_removed" + ) + end + + test "audits multiple channels in one import without step-name collisions", + %{project: %{id: project_id} = project, user: %{id: user_id} = user} do + pc_a = insert(:project_credential, project: project) + pc_b = insert(:project_credential, project: project) + + channel_a_id = Ecto.UUID.generate() + channel_b_id = Ecto.UUID.generate() + + body = %{ + "id" => project_id, + "name" => "test-project", + "channels" => [ + %{ + "id" => channel_a_id, + "name" => "channel-a", + "destination_url" => "https://example.com/a", + "enabled" => true, + "destination_credential_id" => pc_a.id + }, + %{ + "id" => channel_b_id, + "name" => "channel-b", + "destination_url" => "https://example.com/b", + "enabled" => true, + "destination_credential_id" => pc_b.id + } + ] + } + + assert {:ok, _project} = + Provisioner.import_document(project, user, body) + + # Both channels emit "created" and "auth_method_added" audits scoped + # to their own item_id — proves the per-channel Multi.put(:channel, ...) + # path doesn't collide on the shared `:audit_destination_added` key. + for channel_id <- [channel_a_id, channel_b_id] do + assert created = + Repo.one( + from a in Audit, + where: + a.item_id == ^channel_id and + a.item_type == "channel" and + a.event == "created" + ) + + assert created.actor_id == user_id + + assert Repo.one( + from a in Audit, + where: + a.item_id == ^channel_id and a.item_type == "channel" and + a.event == "auth_method_added" + ) + end + end + + test "audits channel changes when the actor is a ProjectRepoConnection", + %{project: %{id: project_id} = project} do + repo_connection = insert(:project_repo_connection, project: project) + pc = insert(:project_credential, project: project) + + channel_id = Ecto.UUID.generate() + + body = %{ + "id" => project_id, + "name" => "test-project", + "channels" => [ + %{ + "id" => channel_id, + "name" => "repo-sync-channel", + "destination_url" => "https://example.com/destination", + "enabled" => true, + "destination_credential_id" => pc.id + } + ] + } + + assert {:ok, _project} = + Provisioner.import_document(project, repo_connection, body) + + assert created_audit = + Repo.one( + from a in Audit, + where: + a.item_id == ^channel_id and a.item_type == "channel" and + a.event == "created" + ) + + assert created_audit.actor_id == repo_connection.id + assert created_audit.actor_type == :project_repo_connection + + assert auth_audit = + Repo.one( + from a in Audit, + where: + a.item_id == ^channel_id and a.item_type == "channel" and + a.event == "auth_method_added" + ) + + assert auth_audit.actor_id == repo_connection.id + assert auth_audit.actor_type == :project_repo_connection + end + + test "rejects channel deletion with a helpful error", %{ + project: %{id: project_id} = project, + user: user + } do + channel = insert(:channel, project: project) + + body = %{ + "id" => project_id, + "name" => "test-project", + "channels" => [ + %{"id" => channel.id, "delete" => true} + ] + } + + assert {:error, changeset} = + Provisioner.import_document(project, user, body) + + assert %{channels: [%{delete: [msg]}]} = flatten_errors(changeset) + assert msg =~ "deletion is not supported" + + # Channel is unchanged in the database + assert Repo.reload(channel) + end + + test "setting a channel's destination_credential_id replaces the existing auth method", + %{project: %{id: project_id} = project, user: user} do + pc_old = insert(:project_credential, project: project) + pc_new = insert(:project_credential, project: project) + + channel = insert(:channel, project: project) + + insert(:channel_auth_method, + channel: channel, + role: :destination, + webhook_auth_method: nil, + project_credential: pc_old + ) + + body = %{ + "id" => project_id, + "name" => "test-project", + "channels" => [ + %{ + "id" => channel.id, + "destination_credential_id" => pc_new.id + } + ] + } + + assert {:ok, %{channels: [updated]}} = + Provisioner.import_document(project, user, body) + + assert %Lightning.Channels.ChannelAuthMethod{ + role: :destination, + project_credential_id: new_pc_id + } = updated.destination_auth_method + + assert new_pc_id == pc_new.id + end + + test "setting destination_credential_id to nil clears the destination auth method", + %{project: %{id: project_id} = project, user: user} do + pc = insert(:project_credential, project: project) + channel = insert(:channel, project: project) + + insert(:channel_auth_method, + channel: channel, + role: :destination, + webhook_auth_method: nil, + project_credential: pc + ) + + body = %{ + "id" => project_id, + "name" => "test-project", + "channels" => [ + %{ + "id" => channel.id, + "destination_credential_id" => nil + } + ] + } + + assert {:ok, %{channels: [updated]}} = + Provisioner.import_document(project, user, body) + + assert updated.destination_auth_method == nil + end + test "usage limiter is called when creating collection", %{ project: %{id: project_id} = project, user: user diff --git a/test/lightning/projects_test.exs b/test/lightning/projects_test.exs index 2a217219ca5..dbe1e7b6229 100644 --- a/test/lightning/projects_test.exs +++ b/test/lightning/projects_test.exs @@ -620,7 +620,7 @@ defmodule Lightning.ProjectsTest do project = project_fixture(name: "newly-created-project") expected_yaml = - "name: newly-created-project\ndescription: null\ncollections: null\ncredentials: null\nworkflows: null" + "name: newly-created-project\ndescription: null\ncollections: null\nchannels: null\ncredentials: null\nworkflows: null" {:ok, generated_yaml} = Projects.export_project(:yaml, project.id) @@ -780,6 +780,55 @@ defmodule Lightning.ProjectsTest do assert generated_yaml =~ expected_yaml_trigger end + test "channels are included in the export with their destination credential" do + user = insert(:user, email: "channel-user@lightning.com") + credential = insert(:credential, name: "channel-cred", user: user) + + project = insert(:project, name: "project-with-channel") + + project_credential = + insert(:project_credential, project: project, credential: credential) + + channel = + insert(:channel, + project: project, + name: "my-channel", + destination_url: "https://example.com/destination", + enabled: true + ) + + insert(:channel_auth_method, + channel: channel, + role: :destination, + webhook_auth_method: nil, + project_credential: project_credential + ) + + channel_only_name = + insert(:channel, + project: project, + name: "no-cred-channel", + destination_url: "https://example.com/other", + enabled: false + ) + + assert {:ok, generated_yaml} = Projects.export_project(:yaml, project.id) + + assert generated_yaml =~ """ + channels: + my-channel: + name: my-channel + destination_url: 'https://example.com/destination' + enabled: true + destination_credential: channel-user@lightning.com-channel-cred + no-cred-channel: + name: #{channel_only_name.name} + destination_url: 'https://example.com/other' + enabled: false + destination_credential: null + """ + end + test "exports canonical project" do project = canonical_project_fixture( diff --git a/test/lightning_web/controllers/api/provisioning_controller_test.exs b/test/lightning_web/controllers/api/provisioning_controller_test.exs index 47c8bbee57b..52cf19ef38a 100644 --- a/test/lightning_web/controllers/api/provisioning_controller_test.exs +++ b/test/lightning_web/controllers/api/provisioning_controller_test.exs @@ -76,6 +76,72 @@ defmodule LightningWeb.API.ProvisioningControllerTest do ] end + test "returns a project with channels", %{ + conn: conn, + user: user + } do + %{id: project_id} = + project = + insert(:project, + project_users: [%{user_id: user.id}] + ) + + project_credential = + insert(:project_credential, + credential: %{name: "dest-cred", body: %{}, user_id: user.id}, + project: project + ) + + %{id: channel_with_cred_id} = + channel_with_cred = + insert(:channel, + project: project, + name: "with-cred", + destination_url: "https://example.com/a", + enabled: true + ) + + insert(:channel_auth_method, + channel: channel_with_cred, + role: :destination, + webhook_auth_method: nil, + project_credential: project_credential + ) + + %{id: channel_without_cred_id} = + insert(:channel, + project: project, + name: "without-cred", + destination_url: "https://example.com/b", + enabled: false + ) + + conn = get(conn, ~p"/api/provision/#{project_id}") + response = json_response(conn, 200) + + assert %{"channels" => channels_resp} = response["data"] + + expected_pc_id = project_credential.id + + assert [ + %{ + "id" => ^channel_with_cred_id, + "name" => "with-cred", + "destination_url" => "https://example.com/a", + "enabled" => true, + "destination_credential_id" => ^expected_pc_id + }, + %{ + "id" => ^channel_without_cred_id, + "name" => "without-cred", + "destination_url" => "https://example.com/b", + "enabled" => false + } = channel_without_cred_json + ] = channels_resp + + refute Map.has_key?(channel_without_cred_json, "destination_credential_id") + end + test "returns a non empty project without credentials", %{ conn: conn, user: user From 13c1a3a093621368293c1d72a26a035de15e12f7 Mon Sep 17 00:00:00 2001 From: Frank Midigo Date: Fri, 15 May 2026 13:18:20 +0300 Subject: [PATCH 2/3] Provisioner: batch channel audits and reuse maybe_add_project_credentials Two follow-ups from the channels-in-provisioner review: 1. Cross-project credential validation. maybe_add_project_credentials/2 now runs inside parse_document/2 so by the time channels and jobs are cast, the changeset's :project_credentials assoc holds the authoritative set of PCs for this project. channel_changeset and job_changeset share a validate_project_credential_in_project/2 helper that rejects any project_credential_id outside that set. Mirrors the existing Job FK constraint message ("credential doesn't exist or isn't available in this project") and closes the gap for the channel destination credential. 2. Batched channel audits. Channels.Audit.audit_auth_method_changes/4 now takes the channel struct directly (no more :channel Multi-state lookup) and generates unique step keys via System.unique_integer/1, so it composes into a larger Multi any number of times. The provisioner folds every channel's audits into a single Multi and runs one transaction instead of one per channel. Channels.create_channel/2 keeps the deferred-id case working via Multi.merge; Channels.update_channel/3 passes the channel directly. Tests cover cross-project rejection for both channel destination credentials and job credentials. --- lib/lightning/channels.ex | 8 +- lib/lightning/channels/audit.ex | 72 ++++++------ lib/lightning/projects/provisioner.ex | 111 +++++++++++++------ test/lightning/projects/provisioner_test.exs | 84 +++++++++++++- 4 files changed, 205 insertions(+), 70 deletions(-) diff --git a/lib/lightning/channels.ex b/lib/lightning/channels.ex index ed1937ebe7d..350efe00013 100644 --- a/lib/lightning/channels.ex +++ b/lib/lightning/channels.ex @@ -200,7 +200,9 @@ defmodule Lightning.Channels do %Ecto.Changeset{} = audit_cs -> Repo.insert(audit_cs) end end) - |> Audit.audit_auth_method_changes(changeset, actor) + |> Multi.merge(fn %{channel: channel} -> + Audit.audit_auth_method_changes(Multi.new(), channel, changeset, actor) + end) |> Repo.transaction() |> case do {:ok, %{channel: channel}} -> {:ok, channel} @@ -224,7 +226,9 @@ defmodule Lightning.Channels do %Ecto.Changeset{} = audit_cs -> Repo.insert(audit_cs) end end) - |> Audit.audit_auth_method_changes(changeset, actor) + |> Multi.merge(fn %{channel: channel} -> + Audit.audit_auth_method_changes(Multi.new(), channel, changeset, actor) + end) |> Repo.transaction() |> case do {:ok, %{channel: channel}} -> {:ok, channel} diff --git a/lib/lightning/channels/audit.ex b/lib/lightning/channels/audit.ex index 7e974d57a53..d3471306f39 100644 --- a/lib/lightning/channels/audit.ex +++ b/lib/lightning/channels/audit.ex @@ -3,7 +3,7 @@ defmodule Lightning.Channels.Audit do Audit trail for channel CRUD and auth method changes. Provides `event/5` (via `Lightning.Auditing.Audit`) for basic CRUD events, - plus `audit_auth_method_changes/3` which derives fine-grained audit events + plus `audit_auth_method_changes/4` which derives fine-grained audit events for client and destination auth method additions, removals, and swaps. """ use Lightning.Auditing.Audit, @@ -19,6 +19,7 @@ defmodule Lightning.Channels.Audit do ] alias Ecto.Multi + alias Lightning.Channels.Channel @doc """ Appends audit events for auth method changes to the given Multi. @@ -27,18 +28,18 @@ defmodule Lightning.Channels.Audit do `:destination_auth_method`, emitting the appropriate added/removed/changed events. No-op when no auth method changes are present. - Expects the parent Multi to expose the channel under the `:channel` key - (e.g. via `Multi.insert(:channel, ...)` or `Multi.put(:channel, ...)`). + Step keys are unique per call, so the helper can be composed into a larger Multi any number of + times — e.g. once per channel when batching audits across channels. """ - def audit_auth_method_changes(multi, changeset, actor) do + def audit_auth_method_changes(multi, %Channel{} = channel, changeset, actor) do multi - |> audit_client_changes(changeset, actor) - |> audit_destination_changes(changeset, actor) + |> audit_client_changes(channel, changeset, actor) + |> audit_destination_changes(channel, changeset, actor) end # --- Client auth methods (has_many) --- - defp audit_client_changes(multi, changeset, actor) do + defp audit_client_changes(multi, channel, changeset, actor) do changes = Ecto.Changeset.get_change(changeset, :client_auth_methods, []) @@ -46,8 +47,8 @@ defmodule Lightning.Channels.Audit do deleted = Enum.filter(changes, &(&1.action == :delete)) multi - |> add_auth_method_audits(:added, inserted, :client, actor) - |> add_auth_method_audits(:removed, deleted, :client, actor) + |> add_auth_method_audits(:added, inserted, :client, channel, actor) + |> add_auth_method_audits(:removed, deleted, :client, channel, actor) end # --- Destination auth method (has_one, on_replace: :delete) --- @@ -58,7 +59,7 @@ defmodule Lightning.Channels.Audit do # 3. Swap (existing → different) → replace produces a delete of old + # insert of new. We emit a single "auth_method_changed" event instead. - defp audit_destination_changes(multi, changeset, actor) do + defp audit_destination_changes(multi, channel, changeset, actor) do old = changeset.data |> Map.get(:destination_auth_method) has_existing? = old != nil and not match?(%Ecto.Association.NotLoaded{}, old) @@ -71,7 +72,7 @@ defmodule Lightning.Channels.Audit do multi nil when has_existing? -> - audit_destination_removed(multi, old, actor) + audit_destination_removed(multi, channel, old, actor) nil -> multi @@ -80,47 +81,54 @@ defmodule Lightning.Channels.Audit do old_fields = fields_for_data(old, :destination) new_fields = fields_for_changeset(new_cs, :destination) - Multi.insert(multi, :audit_destination_changed, fn %{channel: channel} -> + Multi.insert( + multi, + unique_key(:audit_destination_changed), event("auth_method_changed", channel.id, actor, %{ before: old_fields, after: new_fields }) - end) + ) %Ecto.Changeset{action: :insert} = new_cs -> fields = fields_for_changeset(new_cs, :destination) - Multi.insert(multi, :audit_destination_added, fn %{channel: channel} -> + Multi.insert( + multi, + unique_key(:audit_destination_added), event("auth_method_added", channel.id, actor, %{ before: nil, after: fields }) - end) + ) %Ecto.Changeset{action: :delete} when has_existing? -> - audit_destination_removed(multi, old, actor) + audit_destination_removed(multi, channel, old, actor) %Ecto.Changeset{action: :delete} -> multi end end - defp audit_destination_removed(multi, nil, _actor), do: multi + defp audit_destination_removed(multi, _channel, nil, _actor), do: multi - defp audit_destination_removed(multi, old, actor) do + defp audit_destination_removed(multi, channel, old, actor) do old_fields = fields_for_data(old, :destination) - Multi.insert(multi, :audit_destination_removed, fn %{channel: channel} -> + Multi.insert( + multi, + unique_key("audit_destination_removed"), event("auth_method_removed", channel.id, actor, %{ before: old_fields, after: nil }) - end) + ) end - defp add_auth_method_audits(multi, _direction, [], _role, _actor), do: multi + defp add_auth_method_audits(multi, _direction, [], _role, _channel, _actor), + do: multi - defp add_auth_method_audits(multi, direction, changesets, role, actor) do + defp add_auth_method_audits(multi, direction, changesets, role, channel, actor) do {event_name, extract_fields, wrap} = case direction do :added -> @@ -130,24 +138,24 @@ defmodule Lightning.Channels.Audit do {"auth_method_removed", &fields_for_data(&1.data, role), &{&1, nil}} end - changesets - |> Enum.with_index() - |> Enum.reduce(multi, fn {cs, idx}, acc -> + Enum.reduce(changesets, multi, fn cs, acc -> {before, after_val} = wrap.(extract_fields.(cs)) Multi.insert( acc, - :"audit_#{role}_#{event_name}_#{idx}", - fn %{channel: channel} -> - event(event_name, channel.id, actor, %{ - before: before, - after: after_val - }) - end + unique_key("audit_#{role}_#{event_name}"), + event(event_name, channel.id, actor, %{ + before: before, + after: after_val + }) ) end) end + defp unique_key(base) do + "#{base}_#{System.unique_integer([:positive])}" + end + # Extract fields from a changeset (for inserts/new records) defp fields_for_changeset(cs, :client) do %{ diff --git a/lib/lightning/projects/provisioner.ex b/lib/lightning/projects/provisioner.ex index e7daa0a6885..18a6e5fbd94 100644 --- a/lib/lightning/projects/provisioner.ex +++ b/lib/lightning/projects/provisioner.ex @@ -122,9 +122,8 @@ defmodule Lightning.Projects.Provisioner do defp build_import_changeset(project, user_or_repo_connection, data) do project |> preload_dependencies() - |> parse_document(data) + |> parse_document(data, user_or_repo_connection) |> maybe_add_project_user(user_or_repo_connection) - |> maybe_add_project_credentials(user_or_repo_connection) end defp audit_workflows(project_changeset, user_or_repo_connection) do @@ -216,36 +215,27 @@ defmodule Lightning.Projects.Provisioner do ) end - # Iterates each cast_assoc'd channel changeset, running a per-channel - # Multi for audits. Each Multi is independent so the static step names - # used by `Channels.Audit.audit_auth_method_changes/3` - # (`:audit_destination_added`, etc.) don't collide across channels. - # - # `Multi.put(:channel, %Channel{id: channel_id})` supplies the channel - # under the `:channel` key that the shared audit helper expects. defp audit_channels(project_changeset, actor) do project_changeset |> get_assoc(:channels) - |> Enum.reduce_while({:ok, :done}, fn channel_cs, _acc -> - case audit_one_channel(channel_cs, actor) do - :skip -> {:cont, {:ok, :done}} - {:ok, _changes} -> {:cont, {:ok, :done}} - {:error, _, reason, _} -> {:halt, {:error, reason}} - end + |> Enum.reduce(Multi.new(), fn channel_cs, multi -> + append_channel_audits(multi, channel_cs, actor) end) + |> Repo.transaction() + |> normalize_txn() end - defp audit_one_channel(channel_cs, actor) do + defp append_channel_audits(multi, channel_cs, actor) do case classify_channel_audit(channel_cs) do :skip -> - :skip + multi {event, channel_id} -> - Multi.new() - |> Multi.put(:channel, %Channel{id: channel_id}) + channel = %Channel{id: channel_id} + + multi |> append_channel_event_audit(event, channel_id, channel_cs, actor) - |> ChannelAudit.audit_auth_method_changes(channel_cs, actor) - |> Repo.transaction() + |> ChannelAudit.audit_auth_method_changes(channel, channel_cs, actor) end end @@ -270,7 +260,7 @@ defmodule Lightning.Projects.Provisioner do multi %Ecto.Changeset{} = audit_cs -> - Multi.insert(multi, :channel_audit, audit_cs) + Multi.insert(multi, "channel_audit_#{channel_id}", audit_cs) end end @@ -313,13 +303,27 @@ defmodule Lightning.Projects.Provisioner do end end - @spec parse_document(Project.t(), map()) :: Ecto.Changeset.t(Project.t()) - def parse_document(%Project{} = project, data) when is_map(data) do - project - |> project_changeset(data) + @spec parse_document( + Project.t(), + map(), + User.t() | ProjectRepoConnection.t() | nil + ) :: + Ecto.Changeset.t(Project.t()) + def parse_document(project, data, user_or_repo_connection \\ nil) + + def parse_document(%Project{} = project, data, user_or_repo_connection) + when is_map(data) do + changeset = + project + |> project_changeset(data) + |> maybe_add_project_credentials(user_or_repo_connection) + + valid_pc_ids = valid_project_credential_ids(changeset) + + changeset |> cast_assoc(:collections, with: &collection_changeset/2) - |> cast_assoc(:channels, with: &channel_changeset/2) - |> cast_assoc(:workflows, with: &workflow_changeset/2) + |> cast_assoc(:channels, with: &channel_changeset(&1, &2, valid_pc_ids)) + |> cast_assoc(:workflows, with: &workflow_changeset(&1, &2, valid_pc_ids)) |> then(fn changeset -> case WorkflowUsageLimiter.limit_workflows_activation( project, @@ -343,6 +347,13 @@ defmodule Lightning.Projects.Provisioner do end) end + defp valid_project_credential_ids(changeset) do + changeset + |> get_assoc(:project_credentials) + |> Enum.map(&get_field(&1, :id)) + |> Enum.reject(&is_nil/1) + end + defp limit_collection_creation(changeset) do new_collections_count = changeset @@ -499,20 +510,48 @@ defmodule Lightning.Projects.Provisioner do |> Collection.validate() end - defp channel_changeset(channel, attrs) do + defp channel_changeset(channel, attrs, valid_project_credentials) do attrs = maybe_add_destination_auth_param(attrs, channel) channel - |> cast(attrs, [:id, :name, :destination_url, :enabled, :delete]) + |> cast(attrs, [ + :id, + :name, + :destination_url, + :enabled, + :delete + ]) |> validate_required([:id, :name, :destination_url]) |> block_channel_deletion() |> cast_assoc(:destination_auth_method, - with: &ChannelAuthMethod.changeset/2 + with: fn struct, attrs -> + struct + |> ChannelAuthMethod.changeset(attrs) + |> validate_project_credential_in_project( + :project_credential_id, + valid_project_credentials + ) + end ) |> validate_extraneous_params() |> Channel.validate() end + defp validate_project_credential_in_project( + changeset, + field, + valid_project_credentials + ) do + changeset + |> validate_change(field, fn _ky, value -> + if value in valid_project_credentials do + [] + else + [{field, "credential doesnt exist or isn't available in this project"}] + end + end) + end + # Channel deletion is intentionally rejected by the provisioner because # `channel_requests` rows must be drained first (the `on_delete: :restrict` # FK). Users should delete channels through the dashboard, which handles @@ -571,20 +610,20 @@ defmodule Lightning.Projects.Provisioner do end end - defp workflow_changeset(workflow, attrs) do + defp workflow_changeset(workflow, attrs, valid_project_credentials) do workflow |> cast(attrs, [:id, :name, :delete, :deleted_at]) |> optimistic_lock(:lock_version) |> validate_required([:id]) |> maybe_soft_delete_workflow() |> validate_extraneous_params(ignore: ["version_history"]) - |> cast_assoc(:jobs, with: &job_changeset/2) + |> cast_assoc(:jobs, with: &job_changeset(&1, &2, valid_project_credentials)) |> cast_assoc(:triggers, with: &trigger_changeset/2) |> cast_assoc(:edges, with: &edge_changeset/2) |> Workflow.validate() end - defp job_changeset(job, attrs) do + defp job_changeset(job, attrs, valid_project_credentials) do job |> Job.changeset(attrs) |> cast(attrs, [:delete]) @@ -592,6 +631,10 @@ defmodule Lightning.Projects.Provisioner do |> unique_constraint(:id, name: :jobs_pkey) |> validate_extraneous_params() |> maybe_mark_for_deletion() + |> validate_project_credential_in_project( + :project_credential_id, + valid_project_credentials + ) end defp trigger_changeset(trigger, attrs) do diff --git a/test/lightning/projects/provisioner_test.exs b/test/lightning/projects/provisioner_test.exs index 3738c92cd28..77f9ff8e3bd 100644 --- a/test/lightning/projects/provisioner_test.exs +++ b/test/lightning/projects/provisioner_test.exs @@ -1362,8 +1362,8 @@ defmodule Lightning.Projects.ProvisionerTest do Provisioner.import_document(project, user, body) # Both channels emit "created" and "auth_method_added" audits scoped - # to their own item_id — proves the per-channel Multi.put(:channel, ...) - # path doesn't collide on the shared `:audit_destination_added` key. + # to their own item_id — proves the batched single-Multi path doesn't + # collide on shared audit step keys. for channel_id <- [channel_a_id, channel_b_id] do assert created = Repo.one( @@ -1456,6 +1456,86 @@ defmodule Lightning.Projects.ProvisionerTest do assert Repo.reload(channel) end + test "rejects a destination_credential_id from another project", %{ + project: %{id: project_id} = project, + user: user + } do + other_project = insert(:project) + foreign_pc = insert(:project_credential, project: other_project) + + channel_id = Ecto.UUID.generate() + + body = %{ + "id" => project_id, + "name" => "test-project", + "channels" => [ + %{ + "id" => channel_id, + "name" => "leaky-channel", + "destination_url" => "https://example.com/destination", + "enabled" => true, + "destination_credential_id" => foreign_pc.id + } + ] + } + + assert {:error, changeset} = + Provisioner.import_document(project, user, body) + + assert %{ + channels: [ + %{destination_auth_method: %{project_credential_id: [msg]}} + ] + } = flatten_errors(changeset) + + assert msg =~ "isn't available in this project" + + # Channel was not persisted + refute Repo.get(Lightning.Channels.Channel, channel_id) + end + + test "rejects a job project_credential_id from another project", %{ + project: %{id: project_id} = project, + user: user + } do + other_project = insert(:project) + foreign_pc = insert(:project_credential, project: other_project) + + %{ + body: %{"workflows" => [workflow]} = body, + workflows: [%{first_job_id: first_job_id}] + } = + valid_document(project_id) + + tainted_jobs = + Enum.map(workflow["jobs"], fn job -> + if job["id"] == first_job_id do + Map.put(job, "project_credential_id", foreign_pc.id) + else + job + end + end) + + body = Map.put(body, "workflows", [%{workflow | "jobs" => tainted_jobs}]) + + assert {:error, changeset} = + Provisioner.import_document(project, user, body) + + assert %{ + workflows: [%{jobs: job_errors}] + } = flatten_errors(changeset) + + assert Enum.any?(job_errors, fn job_error -> + case job_error do + %{project_credential_id: [msg]} -> + msg =~ "isn't available in this project" + + _ -> + false + end + end) + end + test "setting a channel's destination_credential_id replaces the existing auth method", %{project: %{id: project_id} = project, user: user} do pc_old = insert(:project_credential, project: project) From 7a933dfb2c762ac1577f37f2d9fdc01abde80b6a Mon Sep 17 00:00:00 2001 From: Frank Midigo Date: Fri, 15 May 2026 14:05:45 +0300 Subject: [PATCH 3/3] always include destination_credential_id in the state.json even it is nil --- lib/lightning_web/controllers/api/provisioning_json.ex | 1 - .../controllers/api/provisioning_controller_test.exs | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/lightning_web/controllers/api/provisioning_json.ex b/lib/lightning_web/controllers/api/provisioning_json.ex index e3585aa72f7..95a3ebbad8d 100644 --- a/lib/lightning_web/controllers/api/provisioning_json.ex +++ b/lib/lightning_web/controllers/api/provisioning_json.ex @@ -147,7 +147,6 @@ defmodule LightningWeb.API.ProvisioningJSON do enabled: channel.enabled, destination_credential_id: destination_credential_id(channel) } - |> drop_keys_with_nil_value() end defp destination_credential_id(%Channel{destination_auth_method: method}) do diff --git a/test/lightning_web/controllers/api/provisioning_controller_test.exs b/test/lightning_web/controllers/api/provisioning_controller_test.exs index 52cf19ef38a..412a403c0f3 100644 --- a/test/lightning_web/controllers/api/provisioning_controller_test.exs +++ b/test/lightning_web/controllers/api/provisioning_controller_test.exs @@ -135,11 +135,10 @@ defmodule LightningWeb.API.ProvisioningControllerTest do "id" => ^channel_without_cred_id, "name" => "without-cred", "destination_url" => "https://example.com/b", - "enabled" => false - } = channel_without_cred_json + "enabled" => false, + "destination_credential_id" => nil + } ] = channels_resp - - refute Map.has_key?(channel_without_cred_json, "destination_credential_id") end test "returns a non empty project without credentials", %{