diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4d690ff1..1e743ab5c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to - GitHub sync now prevents two projects in the same project tree (root, sandboxes, siblings, and cousins) from claiming the same `(repo, branch)` pair. [#4727](https://github.com/OpenFn/lightning/issues/4727) +- Keychain credentials do not resolve on projects with zero-persistence enabled + [#5091](https://github.com/OpenFn/lightning/issues/5091) +- Ensure run credentials are loaded from the run's snapshot, not the live + workflow ## [2.19.2] - 2026-10-16 diff --git a/lib/lightning/credentials.ex b/lib/lightning/credentials.ex index 4129c6a9d4..e27d48c017 100644 --- a/lib/lightning/credentials.ex +++ b/lib/lightning/credentials.ex @@ -1231,9 +1231,16 @@ defmodule Lightning.Credentials do - `{:ok, body}` - The credential body (with fresh tokens if OAuth was refreshed) - `{:error, :environment_not_found}` - No body exists for this environment - `{:error, oauth_refresh_error()}` - OAuth refresh failed + - `{:error, map() | Ecto.Changeset.t()}` - Any other refresh failure (e.g. a + timeout), or the refreshed token could not be saved """ @spec resolve_credential_body(Credential.t(), String.t()) :: - {:ok, map()} | {:error, :environment_not_found | oauth_refresh_error()} + {:ok, map()} + | {:error, + :environment_not_found + | oauth_refresh_error() + | map() + | Ecto.Changeset.t()} def resolve_credential_body(%Credential{} = credential, environment) do case get_credential_body(credential.id, environment) do nil -> diff --git a/lib/lightning/credentials/resolver.ex b/lib/lightning/credentials/resolver.ex index 73e1aadacd..6d82158093 100644 --- a/lib/lightning/credentials/resolver.ex +++ b/lib/lightning/credentials/resolver.ex @@ -43,10 +43,12 @@ defmodule Lightning.Credentials.Resolver do alias Lightning.Credentials.Credential alias Lightning.Credentials.KeychainCredential alias Lightning.Credentials.ResolvedCredential + alias Lightning.Invocation.Dataclip alias Lightning.Projects.Environment alias Lightning.Projects.ProjectCredential alias Lightning.Repo alias Lightning.Run + alias Lightning.Workflows.Workflow require Logger @@ -60,23 +62,57 @@ defmodule Lightning.Credentials.Resolver do @type resolve_error :: {error_reason(), Credential.t() | nil} + @type keychain_selectors :: %{ + Ecto.UUID.t() => + {:ok, external_id :: String.t() | nil} + | {:error, :keychain_input_unavailable | :invalid_keychain_path} + } + @doc """ Resolves a credential for a run by matching the project's environment, or resolves a credential directly for a specific environment. + + For a run, keychain credentials are selected from `keychain_selectors` (see + `keychain_selectors/1`). When it is `nil` they are read from the run's + stored input. """ - @spec resolve_credential(Run.t(), credential_id :: String.t()) :: - {:ok, ResolvedCredential.t() | nil} - | {:error, :not_found | resolve_error()} @spec resolve_credential(Credential.t(), environment :: String.t()) :: {:ok, ResolvedCredential.t()} | {:error, resolve_error()} + @spec resolve_credential(Run.t(), credential_id :: String.t()) :: + {:ok, ResolvedCredential.t() | nil} + | {:error, :not_found | resolve_error()} + def resolve_credential(%Credential{} = credential, environment) do + case Credentials.resolve_credential_body(credential, environment) do + {:ok, body} -> + {:ok, ResolvedCredential.from(credential, body)} + + {:error, reason} -> + log_resolution_error(reason) + {:error, {reason, credential}} + end + end - def resolve_credential(%Run{} = run, id) do + def resolve_credential(%Run{} = run, id), + do: resolve_credential(run, id, nil) + + @spec resolve_credential( + Run.t(), + credential_id :: String.t(), + keychain_selectors() | nil + ) :: + {:ok, ResolvedCredential.t() | nil} + | {:error, :not_found | resolve_error()} + def resolve_credential(%Run{} = run, id, keychain_selectors) do Logger.metadata(run_id: run.id, credential_id: id) with {:ok, project_env} <- get_project_env(run), - credential when not is_nil(credential) <- get_run_credential(run, id) do - resolve_credential_with_env(credential, run, project_env) + credential when not is_nil(credential) <- get_run_credential(run, id), + {:ok, credential} <- + select_credential(credential, run, keychain_selectors) do + if credential, + do: resolve_credential_with_env(credential, run, project_env), + else: {:ok, nil} else nil -> {:error, :not_found} @@ -86,17 +122,90 @@ defmodule Lightning.Credentials.Resolver do {:error, :project_not_found} -> {:error, {:project_not_found, nil}} + + {:error, :keychain_input_unavailable} -> + {:error, {:keychain_input_unavailable, nil}} + + {:error, :invalid_keychain_path} -> + {:error, {:invalid_keychain_path, nil}} end end - def resolve_credential(%Credential{} = credential, environment) do - case Credentials.resolve_credential_body(credential, environment) do - {:ok, body} -> - {:ok, ResolvedCredential.from(credential, body)} + @doc """ + Reads, for each keychain the run's jobs use, the external id its path selects + from the run's input. - {:error, reason} -> - log_resolution_error(reason) - {:error, {reason, credential}} + Projects that don't save dataclips have the input wiped as soon as the worker + fetches it, which is before it asks for any credential. Call this before the + wipe and pass the result to `resolve_credential/3`. + """ + @spec keychain_selectors(Run.t()) :: keychain_selectors() + def keychain_selectors(%Run{} = run) do + %{snapshot: %{jobs: jobs}, dataclip_id: dataclip_id} = + Repo.preload(run, :snapshot) + + keychain_ids = + for %{keychain_credential_id: id} <- jobs, id, uniq: true, do: id + + keychain_selectors(dataclip_id, keychain_ids) + end + + defp keychain_selectors(_dataclip_id, []), do: %{} + + defp keychain_selectors(dataclip_id, keychain_ids) do + # One query per keychain, so a path Postgres can't parse or evaluate only + # breaks the job that uses it, not every keychain in the run. + Map.new(keychain_ids, &{&1, select_external_id(dataclip_id, &1)}) + end + + defp select_external_id(dataclip_id, keychain_id) do + from(k in KeychainCredential, + join: d in Dataclip, + on: d.id == ^dataclip_id, + where: k.id == ^keychain_id and is_nil(d.wiped_at), + select: %{ + external_id: + fragment( + "jsonb_path_query_first(?, ?::jsonpath) #>> '{}'", + d.body, + k.path + ) + } + ) + |> Repo.one() + |> case do + %{external_id: external_id} -> {:ok, external_id} + nil -> {:error, :keychain_input_unavailable} + end + rescue + Postgrex.Error -> {:error, :invalid_keychain_path} + end + + defp select_credential(%Credential{} = credential, _run, _selectors), + do: {:ok, credential} + + defp select_credential(%KeychainCredential{} = keychain, run, nil), + do: + select_credential( + keychain, + run, + keychain_selectors(run.dataclip_id, [keychain.id]) + ) + + defp select_credential(%KeychainCredential{} = keychain, run, selectors) do + case Map.fetch!(selectors, keychain.id) do + {:ok, external_id} -> + {:ok, + find_credential_by_external_id(run, external_id) || + keychain.default_credential} + + {:error, reason} = error -> + log_resolution_error(reason, + keychain_id: keychain.id, + path: keychain.path + ) + + error end end @@ -115,22 +224,6 @@ defmodule Lightning.Credentials.Resolver do end end - defp resolve_credential_with_env( - %KeychainCredential{} = keychain, - run, - project_env - ) do - credential = - find_credential_by_jsonpath(run, keychain.path) || - keychain.default_credential - - if credential do - resolve_credential_with_env(credential, run, project_env) - else - {:ok, nil} - end - end - @spec get_project_env(Run.t()) :: {:ok, String.t()} | {:error, term()} defp get_project_env(%Run{} = run) do case Environment.fetch(run) do @@ -143,23 +236,9 @@ defmodule Lightning.Credentials.Resolver do end end - @spec find_credential_by_jsonpath(Run.t(), String.t()) :: - Credential.t() | nil - defp find_credential_by_jsonpath( - %Run{dataclip_id: dataclip_id} = run, - jsonpath - ) do - get_external_id_query = - from(d in Lightning.Invocation.Dataclip, - where: d.id == ^dataclip_id, - select: - fragment( - "jsonb_path_query_first(?, ?::jsonpath) #>> '{}'", - d.body, - type(^jsonpath, :string) - ) - ) + defp find_credential_by_external_id(_run, nil), do: nil + defp find_credential_by_external_id(run, external_id) do from( c in Ecto.assoc(run, [ :work_order, @@ -168,7 +247,7 @@ defmodule Lightning.Credentials.Resolver do :project_credentials, :credential ]), - where: c.external_id == subquery(get_external_id_query) + where: c.external_id == ^external_id ) |> Repo.one() end @@ -176,47 +255,54 @@ defmodule Lightning.Credentials.Resolver do @spec get_run_credential(Run.t(), String.t()) :: Credential.t() | KeychainCredential.t() | nil defp get_run_credential(%Run{} = run, id) do + # The worker was given the credential ids in the run's snapshot, so that is + # what it may ask for, even if the live workflow has changed since. + %{snapshot: %{workflow_id: workflow_id, jobs: jobs}} = + Repo.preload(run, :snapshot) + + project_credential_ids = + for %{project_credential_id: pc_id} <- jobs, pc_id, do: pc_id + + keychain_ids = + for %{keychain_credential_id: k_id} <- jobs, k_id, do: k_id + + from(c in Credential, + join: pc in assoc(c, :project_credentials), + join: w in Workflow, + on: w.id == ^workflow_id and pc.project_id == w.project_id, + where: c.id == ^id and pc.id in ^project_credential_ids + ) + |> Repo.one() || get_run_keychain(workflow_id, id, keychain_ids) + end + + defp get_run_keychain(workflow_id, id, keychain_ids) do # The default credential is joined through the project this run belongs to, # not off what the stored id implies and not through the keychain's own - # project. The - # write-time guard can only speak for rows written after it; this speaks - # for every row, including any already in the database from before the - # guard worked, or written by a path that bypasses the changeset entirely. + # project. The write-time guard can only speak for rows written after it; + # this speaks for every row, including any already in the database from + # before the guard worked, or written by a path that bypasses the changeset + # entirely. # # Anchoring on the run rather than on the keychain matters. A job holding a # keychain from another project would otherwise satisfy this check, since # that keychain's default really is shared with that other project, and the # worker would be handed a credential its own project was never given. It - # is the same project the jsonpath branch above resolves against, so the - # two halves cannot disagree. - from(j in Ecto.assoc(run, [:work_order, :workflow, :jobs]), - left_join: w in assoc(j, :workflow), - left_join: c in assoc(j, :credential), - left_join: k in assoc(j, :keychain_credential), + # is the same project keychain selection resolves against, so the two + # halves cannot disagree. + from(k in KeychainCredential, + join: w in Workflow, + on: w.id == ^workflow_id, left_join: default_cred in assoc(k, :default_credential), left_join: default_pc in ProjectCredential, on: default_pc.credential_id == default_cred.id and default_pc.project_id == w.project_id, - where: c.id == ^id or k.id == ^id, - select: %{ - credential: c, - keychain: k, - default_credential: default_cred, - default_credential_in_project?: not is_nil(default_pc.id) - } + where: k.id == ^id and k.id in ^keychain_ids, + select: {k, default_cred, not is_nil(default_pc.id)} ) |> Repo.one() |> case do - %{credential: %Credential{} = credential, keychain: nil} -> - credential - - %{ - credential: nil, - keychain: %KeychainCredential{} = keychain, - default_credential: default_cred, - default_credential_in_project?: in_project? - } -> + {keychain, default_cred, in_project?} -> %{keychain | default_credential: (in_project? && default_cred) || nil} nil -> @@ -245,5 +331,19 @@ defmodule Lightning.Credentials.Resolver do defp log_resolution_error(:temporary_failure, meta), do: Logger.info("Could not reach the OAuth provider", meta) + defp log_resolution_error(:keychain_input_unavailable, meta), + do: + Logger.warning( + "Keychain input was wiped before this channel could read it", + meta + ) + + defp log_resolution_error(:invalid_keychain_path, meta), + do: + Logger.warning( + "Keychain path could not be evaluated against the run's input", + meta + ) + defp log_resolution_error(_reason, _meta), do: :ok end diff --git a/lib/lightning_web/channels/run_channel.ex b/lib/lightning_web/channels/run_channel.ex index 7ee4d797f1..a2c2e30897 100644 --- a/lib/lightning_web/channels/run_channel.ex +++ b/lib/lightning_web/channels/run_channel.ex @@ -53,7 +53,8 @@ defmodule LightningWeb.RunChannel do run: run, project_id: project_id, scrubber: webhook_auth_scrubber(run), - webhook_response: nil + webhook_response: nil, + keychain_selectors: nil })} else {:error, :not_found} -> @@ -146,7 +147,7 @@ defmodule LightningWeb.RunChannel do Logger.metadata(credential_id: id) - case Resolver.resolve_credential(run, id) do + case Resolver.resolve_credential(run, id, socket.assigns.keychain_selectors) do {:ok, nil} -> reply_and_report(socket, {:ok, nil}) @@ -182,10 +183,17 @@ defmodule LightningWeb.RunChannel do of those HTTP requests to the worker to use as initial state. """ def handle_in("fetch:dataclip", _payload, socket) do - body = Runs.get_input(socket.assigns.run) + %{run: run} = socket.assigns + body = Runs.get_input(run) - if !socket.assigns.run.options.save_dataclips, - do: Runs.wipe_dataclips(socket.assigns.run) + socket = + if run.options.save_dataclips or socket.assigns.keychain_selectors do + socket + else + selectors = Resolver.keychain_selectors(run) + Runs.wipe_dataclips(run) + assign(socket, :keychain_selectors, selectors) + end reply_and_report(socket, {:ok, {:binary, body || "null"}}) end @@ -626,6 +634,32 @@ defmodule LightningWeb.RunChannel do socket} end + defp handle_credential_error( + socket, + {:keychain_input_unavailable, _credential}, + _id, + _project_id, + _run_id + ) do + {:reply, + {:error, + "Could not select a keychain credential: " <> + "the run's input was erased before it could be read"}, socket} + end + + defp handle_credential_error( + socket, + {:invalid_keychain_path, _credential}, + _id, + _project_id, + _run_id + ) do + {:reply, + {:error, + "Could not select a keychain credential: " <> + "its path could not be evaluated against the run's input"}, socket} + end + # Fallback for unexpected error terms defp handle_credential_error(socket, error, id, _project_id, run_id) do Logger.warning( diff --git a/test/lightning/credentials/resolver_test.exs b/test/lightning/credentials/resolver_test.exs index de1aca85f6..2101bfc662 100644 --- a/test/lightning/credentials/resolver_test.exs +++ b/test/lightning/credentials/resolver_test.exs @@ -2,6 +2,7 @@ defmodule ResolverTest do use Lightning.DataCase, async: true alias Lightning.Credentials.Resolver + alias Lightning.Workflows.Snapshot require Logger @@ -446,6 +447,7 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) |> with_run(%{ + snapshot: snapshot_of(workflow), # Create dataclip with user_id = "alice_dhis2" that matches alice's external_id dataclip: build(:dataclip, %{ @@ -482,6 +484,7 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) |> with_run(%{ + snapshot: snapshot_of(workflow), dataclip: build(:dataclip, %{ body: %{ @@ -513,6 +516,7 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) |> with_run(%{ + snapshot: snapshot_of(workflow), # Create dataclip with user_id that doesn't match any credential's external_id dataclip: build(:dataclip, %{ @@ -561,6 +565,7 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) |> with_run(%{ + snapshot: snapshot_of(workflow), dataclip: build(:dataclip, %{ body: %{"user_id" => "nobody_matches_this"} @@ -572,6 +577,30 @@ defmodule ResolverTest do Resolver.resolve_credential(run, keychain_credential.id) end + test "resolves the keychain in the run's snapshot, not the live job", %{ + credential_a: credential_a, + job: job, + keychain_credential: keychain_credential, + workflow: workflow + } do + %{runs: [run]} = + insert(:workorder, workflow: workflow) + |> with_run(%{ + snapshot: snapshot_of(workflow), + dataclip: build(:dataclip, %{body: %{"user_id" => "alice_dhis2"}}), + starting_job: job + }) + + job + |> Ecto.Changeset.change(keychain_credential_id: nil) + |> Repo.update!() + + assert {:ok, resolved} = + Resolver.resolve_credential(run, keychain_credential.id) + + assert resolved.credential.id == credential_a.id + end + test "refuses a keychain belonging to another project entirely", %{ workflow: workflow, job: job @@ -611,6 +640,7 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) |> with_run(%{ + snapshot: snapshot_of(workflow), dataclip: build(:dataclip, %{body: %{"user_id" => "no match"}}), starting_job: job }) @@ -641,6 +671,7 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) |> with_run(%{ + snapshot: snapshot_of(workflow), dataclip: build(:dataclip, %{ body: %{ @@ -694,6 +725,7 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) |> with_run(%{ + snapshot: snapshot_of(workflow), dataclip: build(:dataclip, %{ body: %{ @@ -771,7 +803,11 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) - |> with_run(%{dataclip: dataclip, starting_job: job}) + |> with_run(%{ + snapshot: snapshot_of(workflow), + dataclip: dataclip, + starting_job: job + }) assert {:ok, resolved} = Resolver.resolve_credential(run, credential.id) assert %Lightning.Credentials.ResolvedCredential{} = resolved @@ -779,6 +815,52 @@ defmodule ResolverTest do assert resolved.credential.id == credential.id end + test "resolves the credential in the run's snapshot, not the live job", %{ + project: project, + user: user + } do + [snapshot_credential, live_credential] = + for value <- ["snapshot", "live"] do + insert(:credential, user: user) + |> with_body(%{name: "main", body: %{"key" => value}}) + end + + %{jobs: [job]} = + workflow = + build(:workflow, project: project) + |> with_job(%{ + project_credential: %{ + credential: snapshot_credential, + project: project + } + }) + |> insert() + + %{runs: [run]} = + insert(:workorder, workflow: workflow) + |> with_run(%{ + snapshot: snapshot_of(workflow), + dataclip: insert(:dataclip), + starting_job: job + }) + + live_project_credential = + insert(:project_credential, + project: project, + credential: live_credential + ) + + job + |> Ecto.Changeset.change(project_credential_id: live_project_credential.id) + |> Repo.update!() + + assert {:ok, %{body: %{"key" => "snapshot"}}} = + Resolver.resolve_credential(run, snapshot_credential.id) + + assert {:error, :not_found} = + Resolver.resolve_credential(run, live_credential.id) + end + test "returns not_found when credential doesn't exist", %{user: user} do project = insert(:project, project_users: [%{user: user}]) @@ -792,7 +874,11 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) - |> with_run(%{dataclip: dataclip, starting_job: job}) + |> with_run(%{ + snapshot: snapshot_of(workflow), + dataclip: dataclip, + starting_job: job + }) fake_id = Ecto.UUID.generate() assert {:error, :not_found} = Resolver.resolve_credential(run, fake_id) @@ -817,7 +903,11 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) - |> with_run(%{dataclip: dataclip, starting_job: job}) + |> with_run(%{ + snapshot: snapshot_of(workflow), + dataclip: dataclip, + starting_job: job + }) assert {:error, :not_found} = Resolver.resolve_credential(run, other_credential.id) @@ -844,7 +934,11 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) - |> with_run(%{dataclip: dataclip, starting_job: job}) + |> with_run(%{ + snapshot: snapshot_of(workflow), + dataclip: dataclip, + starting_job: job + }) {result, log} = with_log(fn -> Resolver.resolve_credential(run, credential.id) end) @@ -882,7 +976,11 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) - |> with_run(%{dataclip: dataclip, starting_job: job}) + |> with_run(%{ + snapshot: snapshot_of(workflow), + dataclip: dataclip, + starting_job: job + }) {result, log} = with_log(fn -> Resolver.resolve_credential(run, credential.id) end) @@ -908,7 +1006,11 @@ defmodule ResolverTest do %{runs: [run]} = insert(:workorder, workflow: workflow) - |> with_run(%{dataclip: dataclip, starting_job: job}) + |> with_run(%{ + snapshot: snapshot_of(workflow), + dataclip: dataclip, + starting_job: job + }) # Remove the project so the run's in-memory struct resolves to a # missing project (workflow/workorder/run cascade-delete, but the @@ -928,4 +1030,13 @@ defmodule ResolverTest do assert log =~ "Project not found for run" end end + + # Runs from the factory get an unrelated snapshot; keychains are found + # through the run's snapshot, so give it the workflow's real one. + defp snapshot_of(workflow) do + workflow = Repo.reload!(workflow) + + Snapshot.get_current_for(workflow) || + Snapshot.create(workflow) |> then(fn {:ok, snapshot} -> snapshot end) + end end diff --git a/test/lightning_web/channels/run_channel_test.exs b/test/lightning_web/channels/run_channel_test.exs index ffd5f4d148..c93d5033d1 100644 --- a/test/lightning_web/channels/run_channel_test.exs +++ b/test/lightning_web/channels/run_channel_test.exs @@ -878,6 +878,150 @@ defmodule LightningWeb.RunChannelTest do end end + describe "fetch:credential for a keychain when dataclips are not saved" do + setup do + user = insert(:user) + + project = + insert(:project, + project_users: [%{user: user}], + retention_policy: :erase_all + ) + + [selected, default] = + for {external_id, username} <- [{"alice", "alice"}, {nil, "default"}] do + credential = + insert(:credential, + schema: "raw", + external_id: external_id, + user: user + ) + |> with_body(%{name: "main", body: %{"username" => username}}) + + insert(:project_credential, project: project, credential: credential) + credential + end + + keychain = + insert(:keychain_credential, + path: "$.user_id", + default_credential: default, + project: project, + created_by: user + ) + + workflow = + %{triggers: [trigger]} = + build(:workflow, project: project) + |> with_trigger(build(:trigger, type: :webhook, enabled: true)) + |> with_job(build(:job, keychain_credential: keychain)) + |> insert() + + {:ok, snapshot} = Workflows.Snapshot.create(workflow) + + dataclip = + insert(:http_request_dataclip, + project: project, + body: %{"user_id" => "alice"} + ) + + run = + insert(:run, + work_order: + insert(:workorder, + workflow: workflow, + trigger: trigger, + dataclip: dataclip, + snapshot: snapshot + ), + starting_trigger: trigger, + dataclip: dataclip, + snapshot: snapshot, + options: %{save_dataclips: false} + ) + + %{run: run, keychain: keychain, selected: selected} + |> merge_setups([:create_socket, :join_run_channel]) + end + + test "resolves against the input as it was before the wipe", %{ + socket: socket, + keychain: keychain + } do + ref = push(socket, "fetch:dataclip", %{}) + assert_reply ref, :ok, {:binary, _} + + ref = push(socket, "fetch:credential", %{"id" => keychain.id}) + assert_reply ref, :ok, %{"username" => "alice"} + end + + test "resolves when the credential is fetched before the dataclip", %{ + socket: socket, + keychain: keychain + } do + ref = push(socket, "fetch:credential", %{"id" => keychain.id}) + assert_reply ref, :ok, %{"username" => "alice"} + + ref = push(socket, "fetch:dataclip", %{}) + assert_reply ref, :ok, {:binary, _} + + ref = push(socket, "fetch:credential", %{"id" => keychain.id}) + assert_reply ref, :ok, %{"username" => "alice"} + end + + test "fetching the dataclip again keeps the selection made before the wipe", + %{socket: socket, keychain: keychain} do + for _ <- 1..2 do + ref = push(socket, "fetch:dataclip", %{}) + assert_reply ref, :ok, {:binary, _} + end + + ref = push(socket, "fetch:credential", %{"id" => keychain.id}) + assert_reply ref, :ok, %{"username" => "alice"} + end + + test "a path Postgres can't parse only fails that keychain's credential", %{ + socket: socket, + keychain: keychain + } do + # Passes the changeset's regex, but isn't SQL/JSON path syntax. + keychain |> Ecto.Changeset.change(path: "$..user_id") |> Repo.update!() + + ref = push(socket, "fetch:dataclip", %{}) + assert_reply ref, :ok, {:binary, _} + + ref = push(socket, "fetch:credential", %{"id" => keychain.id}) + assert_reply ref, :error, "Could not select a keychain credential: " <> _ + end + + test "errors rather than falling back to the default when the input was never read", + %{run: run, keychain: keychain} = context do + # A rejoin lands in a fresh channel process; the worker does not fetch + # the dataclip again, and the stored copy is already gone. + Lightning.Runs.wipe_dataclips(run) + + %{socket: socket} = + merge_setups(context, [:create_socket, :join_run_channel]) + + ref = push(socket, "fetch:credential", %{"id" => keychain.id}) + assert_reply ref, :error, _ + end + + test "errors when a rejoined channel fetches the already-wiped dataclip", + %{run: run, keychain: keychain} = context do + Lightning.Runs.wipe_dataclips(run) + + %{socket: socket} = + merge_setups(context, [:create_socket, :join_run_channel]) + + ref = push(socket, "fetch:dataclip", %{}) + assert_reply ref, :ok, {:binary, _} + + ref = push(socket, "fetch:credential", %{"id" => keychain.id}) + assert_reply ref, :error, "Could not select a keychain credential: " <> _ + end + end + describe "marking steps as started and finished" do setup :create_socket