diff --git a/config/test.exs b/config/test.exs index 6d50c87..70b1814 100644 --- a/config/test.exs +++ b/config/test.exs @@ -22,6 +22,8 @@ config :kodo, Kodo.Integrations.CredentialEncryption, current_key_version: "test-v1", keys: %{"test-v1" => :binary.copy(<<1>>, 32), "test-old" => :binary.copy(<<2>>, 32)} +config :kodo, :openai_validation_client, Kodo.Test.FakeOpenAIValidationClient + # We don't run a server during test. If one is required, # you can enable the server option below. config :kodo, KodoWeb.Endpoint, diff --git a/crates/kodo/src/workspace.rs b/crates/kodo/src/workspace.rs index b987bfb..c9e9371 100644 --- a/crates/kodo/src/workspace.rs +++ b/crates/kodo/src/workspace.rs @@ -355,6 +355,8 @@ fn canonicalize(path: &Path) -> Result { mod tests { use std::fs; use std::io::{Read, Seek, SeekFrom}; + use std::sync::mpsc; + use std::thread; use tempfile::TempDir; @@ -375,14 +377,26 @@ mod tests { fn permits_only_one_runner_for_a_workspace() { let repository = git_repository(); let workspace = Workspace::from_root(repository.path()).unwrap(); - let first = workspace.lock_runner().unwrap(); + let holder_workspace = workspace.clone(); + let (locked_tx, locked_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let holder = thread::spawn(move || { + let lock = holder_workspace.lock_runner().unwrap(); + locked_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + drop(lock); + }); + + // Do not let the contender race the thread intended to hold the lock. + locked_rx.recv().unwrap(); assert!(matches!( workspace.lock_runner(), Err(WorkspaceError::RunnerAlreadyActive(path)) if path == workspace.root() )); - drop(first); + release_tx.send(()).unwrap(); + holder.join().unwrap(); workspace.lock_runner().unwrap(); } diff --git a/lib/kodo/integrations.ex b/lib/kodo/integrations.ex index c1e4159..b851430 100644 --- a/lib/kodo/integrations.ex +++ b/lib/kodo/integrations.ex @@ -5,6 +5,7 @@ defmodule Kodo.Integrations do import Ecto.Query alias Kodo.Accounts.Scope + alias Kodo.Integrations.AuditEvent alias Kodo.Integrations.CredentialEncryption alias Kodo.Integrations.Integration alias Kodo.Repo @@ -34,6 +35,13 @@ defmodule Kodo.Integrations do end end + def list_audit_events(%Scope{user: user}) do + AuditEvent + |> where([event], event.actor_user_id == ^user.id) + |> order_by([event], asc: event.inserted_at, asc: event.id) + |> Repo.all() + end + def connect(scope, provider, authentication_type, credentials, opts \\ []) def connect(%Scope{user: user}, provider, "api_key", credentials, opts) do @@ -57,8 +65,7 @@ defmodule Kodo.Integrations do }) ) |> Integration.constraint_changeset() - |> Repo.insert() - |> normalize_insert_result() + |> insert_with_audit(user.id, "api_key_submitted") else false -> {:error, changeset} {:error, _reason} = error -> error @@ -75,8 +82,22 @@ defmodule Kodo.Integrations do generation, credentials, opts, - Integration.connection_statuses(), - "api_key" + ["connected"], + "api_key", + "api_key_replaced" + ) + end + + def reconnect_api_key(%Scope{} = scope, id, generation, credentials, opts \\ []) do + install_credentials( + scope, + id, + generation, + credentials, + opts, + ["disconnected"], + "api_key", + "api_key_submitted" ) end @@ -88,7 +109,8 @@ defmodule Kodo.Integrations do credentials, opts, Integration.connection_statuses(), - "oauth" + "oauth", + "oauth_succeeded" ) end @@ -100,12 +122,13 @@ defmodule Kodo.Integrations do credentials, opts, ~w(connected reauthorization_required), - "oauth" + "oauth", + "refresh_succeeded" ) end def validation_succeeded(%Scope{} = scope, id, generation) do - update_fenced(scope, id, generation, ["connected"], %{ + update_fenced(scope, id, generation, ["connected"], "validation_succeeded", %{ validation_status: "valid", validated_at: now(), validation_error_code: nil @@ -113,7 +136,7 @@ defmodule Kodo.Integrations do end def validation_invalid(%Scope{} = scope, id, generation) do - update_fenced(scope, id, generation, ["connected"], %{ + update_fenced(scope, id, generation, ["connected"], "validation_invalid", %{ validation_status: "invalid", validated_at: now(), validation_error_code: "invalid_credentials" @@ -122,7 +145,7 @@ defmodule Kodo.Integrations do def validation_unavailable(%Scope{} = scope, id, generation, error_code) when error_code in @safe_validation_errors do - update_fenced(scope, id, generation, ["connected"], %{ + update_fenced(scope, id, generation, ["connected"], "validation_unavailable", %{ validation_status: "unavailable", validated_at: now(), validation_error_code: error_code @@ -135,7 +158,7 @@ defmodule Kodo.Integrations do def refresh_invalid_grant(%Scope{} = scope, id, generation) do with {:ok, integration} <- get_integration(scope, id), true <- integration.authentication_type == "oauth" do - update_fenced(scope, id, generation, ["connected"], %{ + update_fenced(scope, id, generation, ["connected"], "refresh_invalid_grant", %{ connection_status: "reauthorization_required", validation_status: "unverified", validated_at: nil, @@ -148,18 +171,25 @@ defmodule Kodo.Integrations do end def disconnect(%Scope{} = scope, id, generation) do - update_fenced(scope, id, generation, Integration.connection_statuses(), %{ - connection_status: "disconnected", - validation_status: "unverified", - encrypted_credentials: nil, - encryption_key_version: nil, - credential_format_version: nil, - credential_generation: generation + 1, - expires_at: nil, - validated_at: nil, - refreshed_at: nil, - validation_error_code: nil - }) + update_fenced( + scope, + id, + generation, + Integration.connection_statuses(), + "integration_disconnected", + %{ + connection_status: "disconnected", + validation_status: "unverified", + encrypted_credentials: nil, + encryption_key_version: nil, + credential_format_version: nil, + credential_generation: generation + 1, + expires_at: nil, + validated_at: nil, + refreshed_at: nil, + validation_error_code: nil + } + ) end def safe_validation_errors, do: @safe_validation_errors @@ -171,7 +201,8 @@ defmodule Kodo.Integrations do credentials, opts, allowed_connections, - authentication_type + authentication_type, + audit_event_type ) do with {:ok, integration} <- get_integration(scope, id), :ok <- require_generation(integration, generation), @@ -188,7 +219,7 @@ defmodule Kodo.Integrations do validation_error_code: nil }) - update_fenced(scope, id, generation, allowed_connections, changes) + update_fenced(scope, id, generation, allowed_connections, audit_event_type, changes) else {:error, _reason} = error -> error end @@ -202,12 +233,23 @@ defmodule Kodo.Integrations do defp require_authentication_type(%Integration{}, _type), do: {:error, :authentication_type_mismatch} - defp update_fenced(%Scope{user: user}, id, generation, allowed_connections, changes) + defp update_fenced( + %Scope{user: user}, + id, + generation, + allowed_connections, + audit_event_type, + changes + ) when is_integer(generation) and generation >= 0 do case Ecto.UUID.cast(id) do {:ok, id} -> Repo.transaction(fn -> - execute_fenced_update(user.id, id, generation, allowed_connections, changes) + integration = + execute_fenced_update(user.id, id, generation, allowed_connections, changes) + + audit!(user.id, integration, audit_event_type) + integration end) :error -> @@ -215,8 +257,15 @@ defmodule Kodo.Integrations do end end - defp update_fenced(%Scope{}, _id, _generation, _allowed_connections, _changes), - do: {:error, :stale_credential_generation} + defp update_fenced( + %Scope{}, + _id, + _generation, + _allowed_connections, + _audit_event_type, + _changes + ), + do: {:error, :stale_credential_generation} defp execute_fenced_update(user_id, id, generation, allowed_connections, changes) do query = @@ -242,6 +291,29 @@ defmodule Kodo.Integrations do defp normalize_insert_result(result), do: result + defp insert_with_audit(changeset, actor_user_id, event_type) do + Repo.transaction(fn -> + case changeset |> Repo.insert() |> normalize_insert_result() do + {:ok, integration} -> + audit!(actor_user_id, integration, event_type) + integration + + {:error, reason} -> + Repo.rollback(reason) + end + end) + end + + defp audit!(actor_user_id, integration, event_type) do + %AuditEvent{actor_user_id: actor_user_id, integration_id: integration.id} + |> AuditEvent.changeset(%{ + provider: integration.provider, + event_type: event_type, + credential_generation: integration.credential_generation + }) + |> Repo.insert!() + end + defp constraint_error?(changeset, type) do Enum.any?(changeset.errors, fn {_field, {_message, metadata}} -> metadata[:constraint] == type diff --git a/lib/kodo/integrations/audit_event.ex b/lib/kodo/integrations/audit_event.ex new file mode 100644 index 0000000..858bcf7 --- /dev/null +++ b/lib/kodo/integrations/audit_event.ex @@ -0,0 +1,42 @@ +defmodule Kodo.Integrations.AuditEvent do + @moduledoc "A credential-free record of a sensitive provider-integration action." + + use Ecto.Schema + import Ecto.Changeset + + @event_types ~w( + api_key_submitted + api_key_replaced + validation_succeeded + validation_invalid + validation_unavailable + integration_disconnected + oauth_succeeded + refresh_succeeded + refresh_invalid_grant + ) + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "integration_audit_events" do + field :provider, :string + field :event_type, :string + field :credential_generation, :integer + + belongs_to :actor_user, Kodo.Accounts.User, type: :id + belongs_to :integration, Kodo.Integrations.Integration + + timestamps(type: :utc_datetime_usec, updated_at: false) + end + + @doc false + def changeset(event, attrs) do + event + |> cast(attrs, [:provider, :event_type, :credential_generation]) + |> validate_required([:provider, :event_type, :credential_generation]) + |> validate_inclusion(:event_type, @event_types) + |> foreign_key_constraint(:actor_user_id) + |> foreign_key_constraint(:integration_id) + end +end diff --git a/lib/kodo/integrations/openai_validation.ex b/lib/kodo/integrations/openai_validation.ex new file mode 100644 index 0000000..725b1e8 --- /dev/null +++ b/lib/kodo/integrations/openai_validation.ex @@ -0,0 +1,151 @@ +defmodule Kodo.Integrations.OpenAIValidation do + @moduledoc """ + Runs a bounded, generation-fenced OpenAI API-key metadata probe. + + The task decrypts immediately before its one external operation and retains + neither the key nor provider response. Only bounded validation outcomes are + allowed to cross back into durable integration state. + """ + + alias Kodo.Accounts.Scope + alias Kodo.Integrations + alias Kodo.Integrations.CredentialEncryption + alias Kodo.Integrations.Integration + + @provider "openai" + @probe_timeout 5_000 + + def start( + %Scope{} = scope, + %Integration{id: integration_id, credential_generation: generation} + ) do + Task.Supervisor.async_nolink(Kodo.ControlPlaneTaskSupervisor, fn -> + case validate(scope, integration_id, generation) do + {:ok, _integration} -> :ok + {:error, reason} -> {:error, reason} + end + end) + end + + @doc false + def validate(%Scope{} = scope, id, generation, opts \\ []) do + client = Keyword.get(opts, :client, configured_client()) + timeout = Keyword.get(opts, :timeout, @probe_timeout) + + with {:ok, integration} <- Integrations.get_integration(scope, id), + :ok <- admit(integration, generation), + {:ok, payload} <- CredentialEncryption.decrypt(integration), + {:ok, api_key} <- fetch_api_key(payload), + outcome <- bounded_probe(client, api_key, timeout) do + persist(scope, integration, outcome) + end + end + + defp configured_client do + Application.get_env( + :kodo, + :openai_validation_client, + Kodo.Integrations.ReqOpenAIValidationClient + ) + end + + defp admit( + %Integration{ + provider: @provider, + authentication_type: "api_key", + connection_status: "connected", + credential_generation: generation + }, + generation + ), + do: :ok + + defp admit(%Integration{}, _generation), do: {:error, :stale_credential_generation} + + defp fetch_api_key(%{"api_key" => api_key}) when is_binary(api_key) and api_key != "", + do: {:ok, api_key} + + defp fetch_api_key(_payload), do: {:error, :credential_payload_invalid} + + # The HTTP client's timeouts bound individual transport phases. This outer + # deadline also bounds response decoding and misbehaving client adapters so a + # validation task cannot retain an operation-local key indefinitely. + defp bounded_probe(client, api_key, timeout) when is_integer(timeout) and timeout > 0 do + task = + Task.Supervisor.async_nolink(Kodo.ControlPlaneTaskSupervisor, fn -> + safe_probe(client, api_key) + end) + + case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do + {:ok, outcome} -> outcome + {:exit, _reason} -> {:unavailable, "provider_unavailable"} + nil -> {:unavailable, "timeout"} + end + end + + # Normalize inside the supervised task so its crash logger can never inspect + # a provider exception or response containing operation-local credentials. + defp safe_probe(client, api_key) do + try do + client.get_models(api_key) |> classify_response() + rescue + _exception -> {:unavailable, "provider_unavailable"} + catch + _kind, _reason -> {:unavailable, "provider_unavailable"} + end + end + + defp classify_response({:ok, status, _body}) when status in 200..299, do: :valid + defp classify_response({:ok, 401, body}), do: classify_unauthorized(body) + + defp classify_response({:ok, status, _body}) when status in 300..399, + do: {:unavailable, "provider_unavailable"} + + defp classify_response({:ok, 429, _body}), do: {:unavailable, "rate_limited"} + defp classify_response({:ok, _status, _body}), do: {:unavailable, "provider_unavailable"} + defp classify_response({:error, :timeout}), do: {:unavailable, "timeout"} + defp classify_response({:error, :tls_error}), do: {:unavailable, "tls_error"} + defp classify_response({:error, :redirect}), do: {:unavailable, "provider_unavailable"} + defp classify_response({:error, :network_error}), do: {:unavailable, "network_error"} + defp classify_response({:error, _reason}), do: {:unavailable, "provider_unavailable"} + + defp classify_unauthorized(%{"error" => %{"code" => code}}) + when code in ["invalid_api_key", "key_revoked"], + do: :invalid + + defp classify_unauthorized(_body), do: {:unavailable, "provider_unavailable"} + + defp persist(scope, integration, :valid) do + scope + |> Integrations.validation_succeeded(integration.id, integration.credential_generation) + |> broadcast_result(integration) + end + + defp persist(scope, integration, :invalid) do + scope + |> Integrations.validation_invalid(integration.id, integration.credential_generation) + |> broadcast_result(integration) + end + + defp persist(scope, integration, {:unavailable, error_code}) do + scope + |> Integrations.validation_unavailable( + integration.id, + integration.credential_generation, + error_code + ) + |> broadcast_result(integration) + end + + defp broadcast_result({:ok, validated} = result, integration) do + Phoenix.PubSub.broadcast( + Kodo.PubSub, + "integration:#{integration.user_id}", + {:integration_validation_finished, validated.id, validated.credential_generation} + ) + + result + end + + defp broadcast_result(error, _integration), do: error +end diff --git a/lib/kodo/integrations/openai_validation_client.ex b/lib/kodo/integrations/openai_validation_client.ex new file mode 100644 index 0000000..1fadfca --- /dev/null +++ b/lib/kodo/integrations/openai_validation_client.ex @@ -0,0 +1,7 @@ +defmodule Kodo.Integrations.OpenAIValidationClient do + @moduledoc false + + @callback get_models(api_key :: String.t()) :: + {:ok, status :: non_neg_integer(), body :: term()} + | {:error, :network_error | :timeout | :tls_error | :redirect} +end diff --git a/lib/kodo/integrations/req_openai_validation_client.ex b/lib/kodo/integrations/req_openai_validation_client.ex new file mode 100644 index 0000000..c15f610 --- /dev/null +++ b/lib/kodo/integrations/req_openai_validation_client.ex @@ -0,0 +1,75 @@ +defmodule Kodo.Integrations.ReqOpenAIValidationClient do + @moduledoc """ + Performs OpenAI validation against Kodo's fixed metadata endpoint. + + Redirects are deliberately disabled for credential-bearing requests. Even a + same-origin endpoint move must be reviewed in code rather than forwarding a + user's API key to a response-selected destination. + """ + + @behaviour Kodo.Integrations.OpenAIValidationClient + + @models_url "https://api.openai.com/v1/models" + @timeout 5_000 + + @impl true + def get_models(api_key), do: get_models(api_key, []) + + @doc false + def get_models(api_key, req_options) when is_binary(api_key) and is_list(req_options) do + # Tests may replace only the transport boundary; the credential-bearing + # origin and redirect policy remain immutable even through these seams. + req_options = Keyword.take(req_options, [:plug, :finch_request]) + + options = + [ + url: @models_url, + headers: [{"authorization", "Bearer #{api_key}"}], + max_redirects: 0, + retry: false, + receive_timeout: @timeout, + request_timeout: @timeout, + finch: [ + pool_timeout: @timeout, + conn_opts: [transport_opts: [timeout: @timeout]] + ] + ] ++ req_options + + try do + case Req.get(options) do + {:ok, %Req.Response{status: status, body: body}} -> + {:ok, status, body} + + {:error, %Req.TooManyRedirectsError{}} -> + {:error, :redirect} + + {:error, %Req.TransportError{reason: reason}} -> + {:error, transport_error(reason)} + + {:error, _error} -> + {:error, :network_error} + end + rescue + exception in RuntimeError -> + # Finch currently turns its structured checkout timeout into a + # RuntimeError. Normalize only that dependency-owned failure here; + # unrelated programming errors must remain visible. + if String.starts_with?( + Exception.message(exception), + "Finch was unable to provide a connection within the timeout" + ) do + {:error, :timeout} + else + reraise exception, __STACKTRACE__ + end + end + end + + defp transport_error(:timeout), do: :timeout + + defp transport_error(reason) when reason in [:closed, :econnrefused, :nxdomain], + do: :network_error + + defp transport_error({:tls_alert, _detail}), do: :tls_error + defp transport_error(_reason), do: :network_error +end diff --git a/lib/kodo_web/components/layouts.ex b/lib/kodo_web/components/layouts.ex index e33644d..2be3f97 100644 --- a/lib/kodo_web/components/layouts.ex +++ b/lib/kodo_web/components/layouts.ex @@ -184,6 +184,85 @@ defmodule KodoWeb.Layouts do """ end + @doc "Renders the responsive navigation and detail surface shared by user settings pages." + attr :title, :string, required: true + attr :subtitle, :string, required: true + attr :return_to, :string, required: true + + slot :section, required: true do + attr :id, :string, required: true + attr :label, :string, required: true + attr :icon, :string, required: true + attr :navigate, :string, required: true + attr :current, :boolean + end + + slot :inner_block, required: true + + def settings_shell(assigns) do + ~H""" +
+
+ <.link + id="settings-return" + navigate={@return_to} + class="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-xl border border-[#d7ddd0] bg-white text-zinc-500 shadow-sm transition hover:-translate-x-0.5 hover:border-zinc-400 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:text-white sm:mt-0" + aria-label="Return to sessions" + > + <.icon name="hero-arrow-left" class="size-4" /> + +
+

+ Settings +

+

+ {@title} +

+

+ {@subtitle} +

+
+
+ +
+ + +
+ {render_slot(@inner_block)} +
+
+
+ """ + end + defp repository_name(%{name: name}) when is_binary(name) and name != "", do: name defp repository_name(%{workspace_root: root}), do: Path.basename(root) diff --git a/lib/kodo_web/controllers/user_session_controller.ex b/lib/kodo_web/controllers/user_session_controller.ex index b75c197..1846482 100644 --- a/lib/kodo_web/controllers/user_session_controller.ex +++ b/lib/kodo_web/controllers/user_session_controller.ex @@ -4,6 +4,16 @@ defmodule KodoWeb.UserSessionController do alias Kodo.Accounts alias KodoWeb.UserAuth + def reauthenticate(conn, %{"provider" => "openai", "action" => action}) + when action in ~w(connect replace disconnect) do + conn + |> put_session(:user_return_to, ~p"/integrations?action=#{action}") + |> put_flash(:info, "Re-authenticate to continue managing your OpenAI integration.") + |> redirect(to: ~p"/users/log-in") + end + + def reauthenticate(conn, _params), do: redirect(conn, to: ~p"/integrations") + def create(conn, %{"_action" => "confirmed"} = params) do create(conn, params, "User confirmed successfully.") end diff --git a/lib/kodo_web/live/integrations_live.ex b/lib/kodo_web/live/integrations_live.ex new file mode 100644 index 0000000..d65a977 --- /dev/null +++ b/lib/kodo_web/live/integrations_live.ex @@ -0,0 +1,485 @@ +defmodule KodoWeb.IntegrationsLive do + use KodoWeb, :live_view + + alias Kodo.Accounts + alias Kodo.Integrations + alias Kodo.Integrations.OpenAIValidation + + @provider "openai" + @sensitive_actions ~w(connect replace disconnect) + @max_api_key_bytes 4_096 + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(Kodo.PubSub, "integration:#{socket.assigns.current_scope.user.id}") + end + + {:ok, + socket + |> assign(:action, nil) + |> assign(:action_target, nil) + |> assign(:max_api_key_bytes, @max_api_key_bytes) + |> assign(:api_key_form, to_form(%{"api_key" => ""}, as: :integration)) + |> assign(:validation_tasks, %{}) + |> load_integration()} + end + + @impl true + def handle_params(%{"action" => action}, _uri, socket) when action in @sensitive_actions do + if sudo_mode?(socket) do + socket = load_integration(socket) + + case action_target(action, socket.assigns.integration) do + {:ok, target} -> + {:noreply, socket |> assign(:action, action) |> assign(:action_target, target)} + + :error -> + stale_action(socket) + end + else + {:noreply, + socket + |> put_flash(:error, "Re-authenticate before continuing. Your API key was not retained.") + |> redirect(to: reauthentication_path(action))} + end + end + + def handle_params(_params, _uri, socket) do + {:noreply, + socket + |> assign(:action, nil) + |> assign(:action_target, nil) + |> load_integration()} + end + + @impl true + def handle_event("save_api_key", %{"integration" => %{"api_key" => api_key}}, socket) do + with true <- socket.assigns.action in ~w(connect replace), + true <- sudo_mode?(socket), + :ok <- validate_api_key(api_key), + {:ok, integration} <- save_api_key(socket, api_key) do + {:noreply, + socket + |> start_validation(integration) + |> put_flash(:info, "OpenAI API key saved. Validation is pending.") + |> push_patch(to: ~p"/integrations")} + else + false -> + reauthenticate(socket) + + {:error, :invalid_api_key_input} -> + {:noreply, put_flash(socket, :error, "Enter an API key.")} + + {:error, :integration_already_exists} -> + stale_action(socket) + + {:error, :stale_credential_generation} -> + stale_action(socket) + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "The API key could not be saved.")} + end + end + + def handle_event("disconnect", _params, socket) do + with true <- socket.assigns.action == "disconnect", + true <- sudo_mode?(socket), + %{id: id, credential_generation: generation, connection_status: "connected"} <- + socket.assigns.action_target, + {:ok, _integration} <- + Integrations.disconnect(socket.assigns.current_scope, id, generation) do + {:noreply, + socket + |> put_flash(:info, "OpenAI disconnected from Kodo.") + |> push_patch(to: ~p"/integrations")} + else + false -> + reauthenticate(socket) + + nil -> + stale_action(socket) + + {:error, :stale_credential_generation} -> + stale_action(socket) + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "OpenAI could not be disconnected.")} + end + end + + @impl true + def handle_info({reference, _result}, socket) when is_reference(reference) do + if Map.has_key?(socket.assigns.validation_tasks, reference) do + Process.demonitor(reference, [:flush]) + {:noreply, socket |> drop_validation_task(reference) |> load_integration()} + else + {:noreply, socket} + end + end + + def handle_info({:DOWN, reference, :process, _pid, _reason}, socket) do + if Map.has_key?(socket.assigns.validation_tasks, reference) do + {:noreply, socket |> drop_validation_task(reference) |> load_integration()} + else + {:noreply, socket} + end + end + + def handle_info({:integration_validation_finished, _id, _generation}, socket) do + {:noreply, load_integration(socket)} + end + + defp save_api_key( + %{assigns: %{action: "connect", action_target: :missing}} = socket, + api_key + ) do + Integrations.connect(socket.assigns.current_scope, @provider, "api_key", %{ + "api_key" => api_key + }) + end + + defp save_api_key( + %{ + assigns: %{ + action: "connect", + action_target: %{ + id: id, + credential_generation: generation, + connection_status: "disconnected" + } + } + } = socket, + api_key + ) do + Integrations.reconnect_api_key( + socket.assigns.current_scope, + id, + generation, + %{"api_key" => api_key} + ) + end + + defp save_api_key( + %{ + assigns: %{ + action: "replace", + action_target: %{ + id: id, + credential_generation: generation, + connection_status: "connected" + } + } + } = socket, + api_key + ) do + Integrations.replace_credentials( + socket.assigns.current_scope, + id, + generation, + %{"api_key" => api_key} + ) + end + + defp save_api_key(_socket, _api_key), do: {:error, :stale_credential_generation} + + defp load_integration(socket) do + integration = + case Integrations.get_integration_by_provider(socket.assigns.current_scope, @provider) do + {:ok, integration} -> integration_metadata(integration) + {:error, :integration_not_found} -> nil + end + + assign(socket, :integration, integration) + end + + # Browser-facing state needs lifecycle metadata only. In particular, keeping + # ciphertext in the LiveView would widen credential retention for no benefit. + defp integration_metadata(integration) do + Map.take(integration, [ + :id, + :provider, + :connection_status, + :validation_status, + :credential_generation, + :validated_at, + :validation_error_code + ]) + end + + defp start_validation(socket, integration) do + task = OpenAIValidation.start(socket.assigns.current_scope, integration) + + update(socket, :validation_tasks, fn tasks -> + Map.put(tasks, task.ref, {integration.id, integration.credential_generation}) + end) + end + + defp drop_validation_task(socket, reference) do + update(socket, :validation_tasks, &Map.delete(&1, reference)) + end + + defp validate_api_key(api_key) + when is_binary(api_key) and byte_size(api_key) > 0 and + byte_size(api_key) <= @max_api_key_bytes, + do: :ok + + defp validate_api_key(_api_key), do: {:error, :invalid_api_key_input} + + defp sudo_mode?(socket), do: Accounts.sudo_mode?(socket.assigns.current_scope.user, -10) + + defp reauthenticate(socket) do + action = socket.assigns.action || "connect" + + {:noreply, + socket + |> put_flash(:error, "Re-authenticate and enter the API key again.") + |> redirect(to: reauthentication_path(action))} + end + + defp stale_action(socket) do + {:noreply, + socket + |> put_flash(:error, "The integration changed in another session. Review its current state.") + |> push_patch(to: ~p"/integrations")} + end + + defp reauthentication_path(action), + do: ~p"/users/reauthenticate/#{@provider}/#{action}" + + defp action_target("connect", nil), do: {:ok, :missing} + + defp action_target("connect", %{connection_status: "disconnected"} = integration), + do: {:ok, target_metadata(integration)} + + defp action_target(action, %{connection_status: "connected"} = integration) + when action in ~w(replace disconnect), + do: {:ok, target_metadata(integration)} + + defp action_target(_action, _integration), do: :error + + defp target_metadata(integration) do + Map.take(integration, [:id, :credential_generation, :connection_status]) + end + + defp validation_running?(tasks, %{id: id, credential_generation: generation}) do + Enum.any?(tasks, fn {_ref, target} -> target == {id, generation} end) + end + + defp validation_running?(_tasks, _integration), do: false + + defp integration_connected?(%{connection_status: "connected"}), do: true + defp integration_connected?(_integration), do: false + + defp status_label(nil), do: "Not connected" + defp status_label(%{connection_status: "connected"}), do: "Connected" + defp status_label(%{connection_status: "reauthorization_required"}), do: "Action required" + defp status_label(%{connection_status: "disconnected"}), do: "Disconnected" + + defp validation_label(%{validation_status: "unverified"}), do: "Pending validation" + defp validation_label(%{validation_status: "valid"}), do: "Validated" + defp validation_label(%{validation_status: "invalid"}), do: "Invalid credential" + defp validation_label(%{validation_status: "unavailable"}), do: "Validation unavailable" + + @impl true + def render(assigns) do + ~H""" + + + <:section + id="settings-nav-account" + label="Account" + icon="hero-user-circle" + navigate={~p"/users/settings"} + /> + <:section + id="settings-nav-integrations" + label="Integrations" + icon="hero-link" + navigate={~p"/integrations"} + current + /> + +
+
+

Model providers

+

+ Connecting a provider does not change existing model routes or billing choices. +

+
+ +

+ + Checking OpenAI connection… +

+ +
+
+
+
+ <.icon name="hero-sparkles" class="size-5" /> +
+
+
+

OpenAI API

+ + Platform billing + +
+

+ Use a user-owned OpenAI Platform API key for compatible model requests. +

+
+
+
Connection
+
+ {status_label(@integration)} +
+
+
+
Validation
+
+ {validation_label(@integration)} +
+
+
+
+
+ +
+ <.link + id="openai-connect" + patch={~p"/integrations?action=connect"} + class="inline-flex items-center justify-center rounded-xl bg-zinc-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:-translate-y-0.5 hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200" + > + Connect + +
+
+ <.link + id="openai-replace" + patch={~p"/integrations?action=replace"} + class="rounded-xl border border-zinc-300 bg-white px-3.5 py-2 text-sm font-semibold text-zinc-700 transition hover:border-zinc-400 hover:text-zinc-950 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:text-white" + > + Replace + + <.link + id="openai-disconnect" + patch={~p"/integrations?action=disconnect"} + class="rounded-xl border border-red-200 bg-white px-3.5 py-2 text-sm font-semibold text-red-700 transition hover:border-red-300 hover:bg-red-50 dark:border-red-950 dark:bg-zinc-900 dark:text-red-400 dark:hover:bg-red-950/30" + > + Disconnect + +
+
+ +
+

+ {if(@action == "replace", do: "Replace API key", else: "Connect OpenAI")} +

+

+ The key is encrypted immediately and is never shown again. +

+ <.form + for={@api_key_form} + id="openai-api-key-form" + phx-submit="save_api_key" + class="mt-4 max-w-xl" + > + <.input + field={@api_key_form[:api_key]} + type="password" + label="OpenAI API key" + autocomplete="off" + maxlength={@max_api_key_bytes} + required + /> +
+ <.link + patch={~p"/integrations"} + class="rounded-xl px-4 py-2.5 text-sm font-semibold text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-white" + > + Cancel + + +
+ +
+ +
+

Disconnect OpenAI?

+

+ Future Kodo requests will stop. A provider operation already admitted or sent may finish and may still incur charges. + <.link + id="openai-revoke-key-link" + href="https://platform.openai.com/api-keys" + target="_blank" + rel="noopener noreferrer" + class="font-semibold text-red-800 underline decoration-red-300 underline-offset-2 hover:text-red-950 dark:text-red-300 dark:hover:text-red-200" + > + Revoke the key in OpenAI (opens in a new tab) + + if it must stop outside Kodo. +

+
+ <.link + patch={~p"/integrations"} + class="rounded-xl px-4 py-2.5 text-sm font-semibold text-zinc-600 transition hover:bg-white dark:text-zinc-400 dark:hover:bg-zinc-800" + > + Keep connected + + +
+
+
+
+
+
+ """ + end +end diff --git a/lib/kodo_web/live/user_live/settings.ex b/lib/kodo_web/live/user_live/settings.ex index b32e834..23f1c75 100644 --- a/lib/kodo_web/live/user_live/settings.ex +++ b/lib/kodo_web/live/user_live/settings.ex @@ -8,63 +8,114 @@ defmodule KodoWeb.UserLive.Settings do @impl true def render(assigns) do ~H""" - -
- <.header> - Account Settings - <:subtitle>Manage your account email address and password settings - -
- - <.form for={@email_form} id="email_form" phx-submit="update_email" phx-change="validate_email"> - <.input - field={@email_form[:email]} - type="email" - label="Email" - autocomplete="username" - spellcheck="false" - required - /> - <.button variant="primary" phx-disable-with="Changing...">Change Email - - -
- - <.form - for={@password_form} - id="password_form" - action={~p"/users/update-password"} - method="post" - phx-change="validate_password" - phx-submit="update_password" - phx-trigger-action={@trigger_submit} + + - - <.input - field={@password_form[:password]} - type="password" - label="New password" - autocomplete="new-password" - spellcheck="false" - required + <:section + id="settings-nav-account" + label="Account" + icon="hero-user-circle" + navigate={~p"/users/settings"} + current /> - <.input - field={@password_form[:password_confirmation]} - type="password" - label="Confirm new password" - autocomplete="new-password" - spellcheck="false" + <:section + id="settings-nav-integrations" + label="Integrations" + icon="hero-link" + navigate={~p"/integrations"} /> - <.button variant="primary" phx-disable-with="Saving..."> - Save Password - - + +
+
+
+

Email address

+

+ We will send a confirmation link before applying a new address. +

+
+ <.form + for={@email_form} + id="email_form" + phx-submit="update_email" + phx-change="validate_email" + class="max-w-xl" + > + <.input + field={@email_form[:email]} + type="email" + label="Email" + autocomplete="username" + spellcheck="false" + required + /> +
+ <.button variant="primary" phx-disable-with="Changing...">Change Email +
+ +
+ +
+
+

Password

+

+ Choose a unique password with at least 12 characters. +

+
+ <.form + for={@password_form} + id="password_form" + action={~p"/users/update-password"} + method="post" + phx-change="validate_password" + phx-submit="update_password" + phx-trigger-action={@trigger_submit} + class="max-w-xl" + > + + <.input + field={@password_form[:password]} + type="password" + label="New password" + autocomplete="new-password" + spellcheck="false" + required + /> + <.input + field={@password_form[:password_confirmation]} + type="password" + label="Confirm new password" + autocomplete="new-password" + spellcheck="false" + /> +
+ <.button variant="primary" phx-disable-with="Saving..."> + Save Password + +
+ +
+
+
""" end diff --git a/lib/kodo_web/router.ex b/lib/kodo_web/router.ex index 293870d..5d2807a 100644 --- a/lib/kodo_web/router.ex +++ b/lib/kodo_web/router.ex @@ -92,8 +92,10 @@ defmodule KodoWeb.Router do live "/sessions/:id", SessionLive.Show, :show live "/users/settings", UserLive.Settings, :edit live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email + live "/integrations", IntegrationsLive, :index end + get "/users/reauthenticate/:provider/:action", UserSessionController, :reauthenticate post "/users/update-password", UserSessionController, :update_password end diff --git a/priv/repo/migrations/20260904051620_create_integration_audit_events.exs b/priv/repo/migrations/20260904051620_create_integration_audit_events.exs new file mode 100644 index 0000000..7ae1076 --- /dev/null +++ b/priv/repo/migrations/20260904051620_create_integration_audit_events.exs @@ -0,0 +1,23 @@ +defmodule Kodo.Repo.Migrations.CreateIntegrationAuditEvents do + use Ecto.Migration + + def change do + create table(:integration_audit_events, primary_key: false) do + add :id, :binary_id, primary_key: true + add :actor_user_id, references(:users, on_delete: :delete_all), null: false + + add :integration_id, + references(:provider_integrations, type: :binary_id, on_delete: :delete_all), + null: false + + add :provider, :string, null: false, size: 32 + add :event_type, :string, null: false, size: 64 + add :credential_generation, :bigint, null: false + + timestamps(type: :utc_datetime_usec, updated_at: false) + end + + create index(:integration_audit_events, [:actor_user_id, :inserted_at]) + create index(:integration_audit_events, [:integration_id, :inserted_at]) + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 7e03f73..96eb928 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -112,6 +112,50 @@ CREATE TABLE public.control_plane_instances ( ); +-- +-- Name: integration_audit_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.integration_audit_events ( + id uuid NOT NULL, + actor_user_id bigint NOT NULL, + integration_id uuid NOT NULL, + provider character varying(32) NOT NULL, + event_type character varying(64) NOT NULL, + credential_generation bigint NOT NULL, + inserted_at timestamp without time zone NOT NULL +); + + +-- +-- Name: provider_integrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.provider_integrations ( + id uuid NOT NULL, + user_id bigint NOT NULL, + provider character varying(32) NOT NULL, + authentication_type character varying(32) NOT NULL, + connection_status character varying(32) DEFAULT 'disconnected'::character varying NOT NULL, + validation_status character varying(32) DEFAULT 'unverified'::character varying NOT NULL, + encrypted_credentials bytea, + encryption_key_version character varying(64), + credential_format_version integer, + credential_generation bigint DEFAULT 0 NOT NULL, + expires_at timestamp without time zone, + validated_at timestamp without time zone, + refreshed_at timestamp without time zone, + validation_error_code character varying(64), + inserted_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + CONSTRAINT provider_integrations_authentication_type_valid CHECK (((authentication_type)::text = ANY ((ARRAY['api_key'::character varying, 'oauth'::character varying])::text[]))), + CONSTRAINT provider_integrations_credential_generation_valid CHECK ((credential_generation >= 0)), + CONSTRAINT provider_integrations_provider_authentication_valid CHECK (((((provider)::text = 'openai_codex'::text) AND ((authentication_type)::text = 'oauth'::text)) OR (((provider)::text = ANY ((ARRAY['openai'::character varying, 'anthropic'::character varying, 'openrouter'::character varying])::text[])) AND ((authentication_type)::text = 'api_key'::text)))), + CONSTRAINT provider_integrations_provider_valid CHECK (((provider)::text = ANY ((ARRAY['openai'::character varying, 'openai_codex'::character varying, 'anthropic'::character varying, 'openrouter'::character varying])::text[]))), + CONSTRAINT provider_integrations_state_valid CHECK (((((connection_status)::text = 'disconnected'::text) AND ((validation_status)::text = 'unverified'::text) AND (encrypted_credentials IS NULL) AND (encryption_key_version IS NULL) AND (credential_format_version IS NULL) AND (expires_at IS NULL) AND (validated_at IS NULL) AND (refreshed_at IS NULL) AND (validation_error_code IS NULL)) OR (((connection_status)::text = 'connected'::text) AND ((validation_status)::text = ANY ((ARRAY['unverified'::character varying, 'valid'::character varying, 'invalid'::character varying, 'unavailable'::character varying])::text[])) AND (encrypted_credentials IS NOT NULL) AND (encryption_key_version IS NOT NULL) AND (credential_format_version IS NOT NULL)) OR (((connection_status)::text = 'reauthorization_required'::text) AND ((validation_status)::text = 'unverified'::text) AND (encrypted_credentials IS NOT NULL) AND (encryption_key_version IS NOT NULL) AND (credential_format_version IS NOT NULL)))) +); + + -- -- Name: runners; Type: TABLE; Schema: public; Owner: - -- @@ -296,6 +340,22 @@ ALTER TABLE ONLY public.control_plane_instances ADD CONSTRAINT control_plane_instances_pkey PRIMARY KEY (boot_id); +-- +-- Name: integration_audit_events integration_audit_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_audit_events + ADD CONSTRAINT integration_audit_events_pkey PRIMARY KEY (id); + + +-- +-- Name: provider_integrations provider_integrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.provider_integrations + ADD CONSTRAINT provider_integrations_pkey PRIMARY KEY (id); + + -- -- Name: runners runners_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -393,6 +453,27 @@ CREATE INDEX control_plane_instances_deployment_generation_artifact_revision ON CREATE INDEX control_plane_instances_last_seen_at_index ON public.control_plane_instances USING btree (last_seen_at); +-- +-- Name: integration_audit_events_actor_user_id_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX integration_audit_events_actor_user_id_inserted_at_index ON public.integration_audit_events USING btree (actor_user_id, inserted_at); + + +-- +-- Name: integration_audit_events_integration_id_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX integration_audit_events_integration_id_inserted_at_index ON public.integration_audit_events USING btree (integration_id, inserted_at); + + +-- +-- Name: provider_integrations_user_id_provider_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX provider_integrations_user_id_provider_index ON public.provider_integrations USING btree (user_id, provider); + + -- -- Name: runners_user_id_index; Type: INDEX; Schema: public; Owner: - -- @@ -508,6 +589,30 @@ ALTER TABLE ONLY public.cluster_placement_overrides ADD CONSTRAINT cluster_placement_overrides_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id) ON DELETE RESTRICT; +-- +-- Name: integration_audit_events integration_audit_events_actor_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_audit_events + ADD CONSTRAINT integration_audit_events_actor_user_id_fkey FOREIGN KEY (actor_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: integration_audit_events integration_audit_events_integration_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_audit_events + ADD CONSTRAINT integration_audit_events_integration_id_fkey FOREIGN KEY (integration_id) REFERENCES public.provider_integrations(id) ON DELETE CASCADE; + + +-- +-- Name: provider_integrations provider_integrations_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.provider_integrations + ADD CONSTRAINT provider_integrations_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + -- -- Name: runners runners_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -575,3 +680,5 @@ INSERT INTO public."schema_migrations" (version) VALUES (20260815051344); INSERT INTO public."schema_migrations" (version) VALUES (20260822084328); INSERT INTO public."schema_migrations" (version) VALUES (20260824012010); INSERT INTO public."schema_migrations" (version) VALUES (20260825042101); +INSERT INTO public."schema_migrations" (version) VALUES (20260903201206); +INSERT INTO public."schema_migrations" (version) VALUES (20260904051620); diff --git a/test/kodo/integrations/audit_event_test.exs b/test/kodo/integrations/audit_event_test.exs new file mode 100644 index 0000000..eb44e93 --- /dev/null +++ b/test/kodo/integrations/audit_event_test.exs @@ -0,0 +1,119 @@ +defmodule Kodo.Integrations.AuditEventTest do + use Kodo.DataCase, async: true + + alias Kodo.AccountsFixtures + alias Kodo.Integrations + alias Kodo.Integrations.AuditEvent + + test "records credential-free lifecycle events for only the owning user" do + scope = AccountsFixtures.user_scope_fixture() + other_scope = AccountsFixtures.user_scope_fixture() + submitted_secret = "audit-must-not-retain-this-key" + replacement_secret = "audit-must-not-retain-replacement" + + assert {:ok, integration} = + Integrations.connect(scope, "openai", "api_key", %{"api_key" => submitted_secret}) + + assert {:ok, replaced} = + Integrations.replace_credentials( + scope, + integration.id, + integration.credential_generation, + %{"api_key" => replacement_secret} + ) + + assert {:ok, validated} = + Integrations.validation_succeeded( + scope, + replaced.id, + replaced.credential_generation + ) + + assert {:ok, _disconnected} = + Integrations.disconnect(scope, validated.id, validated.credential_generation) + + events = Integrations.list_audit_events(scope) + + assert Enum.map(events, & &1.event_type) == [ + "api_key_submitted", + "api_key_replaced", + "validation_succeeded", + "integration_disconnected" + ] + + assert Enum.all?(events, fn event -> + event.actor_user_id == scope.user.id and event.integration_id == integration.id and + event.provider == "openai" + end) + + refute inspect(events) =~ submitted_secret + refute inspect(events) =~ replacement_secret + assert Integrations.list_audit_events(other_scope) == [] + end + + test "rolls back audit records with account deletion" do + scope = AccountsFixtures.user_scope_fixture() + assert {:ok, _integration} = connect_openai(scope) + assert Repo.aggregate(AuditEvent, :count) == 1 + + Repo.delete!(scope.user) + + assert Repo.aggregate(AuditEvent, :count) == 0 + end + + test "records reconnect as a submission with the new generation" do + scope = AccountsFixtures.user_scope_fixture() + {:ok, integration} = connect_openai(scope) + + {:ok, disconnected} = + Integrations.disconnect(scope, integration.id, integration.credential_generation) + + assert {:ok, _reconnected} = + Integrations.reconnect_api_key( + scope, + disconnected.id, + disconnected.credential_generation, + %{"api_key" => "reconnected-secret"} + ) + + assert Enum.map(Integrations.list_audit_events(scope), fn event -> + {event.event_type, event.credential_generation} + end) == [ + {"api_key_submitted", 1}, + {"integration_disconnected", 2}, + {"api_key_submitted", 3} + ] + end + + test "records bounded invalid and unavailable validation outcomes" do + scope = AccountsFixtures.user_scope_fixture() + {:ok, integration} = connect_openai(scope) + + assert {:ok, invalid} = + Integrations.validation_invalid( + scope, + integration.id, + integration.credential_generation + ) + + assert {:ok, _unavailable} = + Integrations.validation_unavailable( + scope, + invalid.id, + invalid.credential_generation, + "rate_limited" + ) + + assert Enum.map(Integrations.list_audit_events(scope), fn event -> + {event.event_type, event.credential_generation} + end) == [ + {"api_key_submitted", 1}, + {"validation_invalid", 1}, + {"validation_unavailable", 1} + ] + end + + defp connect_openai(scope) do + Integrations.connect(scope, "openai", "api_key", %{"api_key" => "audit-cascade-secret"}) + end +end diff --git a/test/kodo/integrations/openai_validation_test.exs b/test/kodo/integrations/openai_validation_test.exs new file mode 100644 index 0000000..39e981c --- /dev/null +++ b/test/kodo/integrations/openai_validation_test.exs @@ -0,0 +1,186 @@ +defmodule Kodo.Integrations.OpenAIValidationTest do + use Kodo.DataCase, async: false + + import ExUnit.CaptureLog + + alias Kodo.AccountsFixtures + alias Kodo.Integrations + alias Kodo.Integrations.OpenAIValidation + alias Kodo.Test.FakeOpenAIValidationClient + + setup do + scope = AccountsFixtures.user_scope_fixture() + %{scope: scope} + end + + for {prefix, status, error_code} <- [ + {"valid-", "valid", nil}, + {"invalid-", "invalid", "invalid_credentials"}, + {"revoked-", "invalid", "invalid_credentials"}, + {"permission-", "unavailable", "provider_unavailable"}, + {"timeout-", "unavailable", "timeout"}, + {"tls-", "unavailable", "tls_error"}, + {"redirect-", "unavailable", "provider_unavailable"}, + {"rate-limited-", "unavailable", "rate_limited"}, + {"provider-error-", "unavailable", "provider_unavailable"} + ] do + test "records #{status} for the bounded #{prefix} outcome", %{scope: scope} do + {:ok, integration} = connect(scope, unquote(prefix) <> "secret") + + assert {:ok, validated} = + OpenAIValidation.validate(scope, integration.id, integration.credential_generation, + client: FakeOpenAIValidationClient + ) + + assert validated.validation_status == unquote(status) + assert validated.validation_error_code == unquote(error_code) + assert validated.credential_generation == integration.credential_generation + end + end + + test "does not classify an unrecognized 401 context as an invalid key", %{scope: scope} do + {:ok, integration} = connect(scope, "permission-secret") + + assert {:ok, validated} = + OpenAIValidation.validate(scope, integration.id, integration.credential_generation, + client: FakeOpenAIValidationClient + ) + + assert validated.validation_status == "unavailable" + end + + test "rejects stale work after replacement", %{scope: scope} do + {:ok, original} = connect(scope, "valid-original") + + {:ok, _replacement} = + Integrations.replace_credentials( + scope, + original.id, + original.credential_generation, + %{"api_key" => "valid-replacement"} + ) + + assert {:error, :stale_credential_generation} = + OpenAIValidation.validate(scope, original.id, original.credential_generation, + client: FakeOpenAIValidationClient + ) + end + + test "does not persist a result admitted before credential replacement", %{scope: scope} do + Application.put_env(:kodo, :fake_openai_validation_test_pid, self()) + + on_exit(fn -> Application.delete_env(:kodo, :fake_openai_validation_test_pid) end) + + {:ok, original} = connect(scope, "blocking-original") + + task = + Task.async(fn -> + OpenAIValidation.validate(scope, original.id, original.credential_generation, + client: FakeOpenAIValidationClient + ) + end) + + assert_receive {:validation_probe_started, probe, _caller} + + {:ok, replacement} = + Integrations.replace_credentials( + scope, + original.id, + original.credential_generation, + %{"api_key" => "valid-replacement"} + ) + + send(probe, {:finish_validation_probe, {:ok, 200, %{"data" => []}}}) + assert Task.await(task) == {:error, :stale_credential_generation} + + assert {:ok, current} = Integrations.get_integration(scope, original.id) + assert current.credential_generation == replacement.credential_generation + assert current.validation_status == "unverified" + end + + test "does not persist a result admitted before disconnection", %{scope: scope} do + Application.put_env(:kodo, :fake_openai_validation_test_pid, self()) + + on_exit(fn -> Application.delete_env(:kodo, :fake_openai_validation_test_pid) end) + + {:ok, original} = connect(scope, "blocking-disconnect") + + task = + Task.async(fn -> + OpenAIValidation.validate(scope, original.id, original.credential_generation, + client: FakeOpenAIValidationClient + ) + end) + + assert_receive {:validation_probe_started, probe, _caller} + + {:ok, disconnected} = + Integrations.disconnect(scope, original.id, original.credential_generation) + + send(probe, {:finish_validation_probe, {:ok, 200, %{"data" => []}}}) + assert Task.await(task) == {:error, :stale_credential_generation} + + assert {:ok, current} = Integrations.get_integration(scope, original.id) + assert current.credential_generation == disconnected.credential_generation + assert current.connection_status == "disconnected" + assert current.validation_status == "unverified" + end + + test "bounds a validation client that never returns", %{scope: scope} do + Application.put_env(:kodo, :fake_openai_validation_test_pid, self()) + + on_exit(fn -> Application.delete_env(:kodo, :fake_openai_validation_test_pid) end) + + {:ok, integration} = connect(scope, "blocking-timeout") + + assert {:ok, validated} = + OpenAIValidation.validate(scope, integration.id, integration.credential_generation, + client: FakeOpenAIValidationClient, + timeout: 10 + ) + + assert_receive {:validation_probe_started, probe, _caller} + probe_ref = Process.monitor(probe) + assert_receive {:DOWN, ^probe_ref, :process, ^probe, _reason} + assert validated.validation_status == "unavailable" + assert validated.validation_error_code == "timeout" + end + + test "normalizes client exceptions before task logging", %{scope: scope} do + secret = "must-not-reach-logs" + {:ok, integration} = connect(scope, "raising-#{secret}") + + log = + capture_log(fn -> + assert {:ok, validated} = + OpenAIValidation.validate( + scope, + integration.id, + integration.credential_generation, + client: FakeOpenAIValidationClient + ) + + assert validated.validation_status == "unavailable" + assert validated.validation_error_code == "provider_unavailable" + end) + + refute log =~ secret + end + + test "enforces ownership before decrypting or probing", %{scope: scope} do + other_scope = AccountsFixtures.user_scope_fixture() + {:ok, integration} = connect(scope, "valid-owned") + + assert {:error, :integration_not_found} = + OpenAIValidation.validate( + other_scope, + integration.id, + integration.credential_generation, + client: FakeOpenAIValidationClient + ) + end + + defp connect(scope, api_key) do + Integrations.connect(scope, "openai", "api_key", %{"api_key" => api_key}) + end +end diff --git a/test/kodo/integrations/req_openai_validation_client_test.exs b/test/kodo/integrations/req_openai_validation_client_test.exs new file mode 100644 index 0000000..fbe0774 --- /dev/null +++ b/test/kodo/integrations/req_openai_validation_client_test.exs @@ -0,0 +1,96 @@ +defmodule Kodo.Integrations.ReqOpenAIValidationClientTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureIO + + alias Kodo.Integrations.ReqOpenAIValidationClient + + test "sends the key only to the fixed OpenAI HTTPS models endpoint" do + secret = "fixed-origin-secret" + + plug = fn conn -> + assert conn.scheme == :https + assert conn.host == "api.openai.com" + assert conn.request_path == "/v1/models" + assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{secret}"] + Req.Test.json(conn, %{"data" => []}) + end + + assert {:ok, 200, %{"data" => []}} = + ReqOpenAIValidationClient.get_models(secret, plug: plug) + end + + test "does not follow same-origin or cross-origin redirects" do + for location <- [ + "https://api.openai.com/v1/other-models", + "https://attacker.example/collect" + ] do + counter = start_supervised!({Agent, fn -> 0 end}, id: {:request_counter, location}) + + plug = fn conn -> + Agent.update(counter, &(&1 + 1)) + + conn + |> Plug.Conn.put_resp_header("location", location) + |> Plug.Conn.send_resp(302, "redirect") + end + + assert {:error, :redirect} = + ReqOpenAIValidationClient.get_models("redirect-secret", plug: plug) + + assert Agent.get(counter, & &1) == 1 + end + end + + test "builds the production Finch request with bounded transport options" do + test_pid = self() + + finch_request = fn request, finch_request, _finch_name, options -> + send(test_pid, {:finch_request, finch_request, options}) + {request, %Req.Response{status: 200, body: %{"data" => []}}} + end + + capture_io(:stderr, fn -> + assert {:ok, 200, %{"data" => []}} = + ReqOpenAIValidationClient.get_models("finch-options-secret", + finch_request: finch_request + ) + end) + + assert_receive {:finch_request, request, options} + assert request.host == "api.openai.com" + assert options[:pool_timeout] == 5_000 + assert options[:receive_timeout] == 5_000 + assert options[:request_timeout] == 5_000 + end + + test "normalizes Finch's pool checkout exception" do + finch_request = fn _request, _finch_request, _finch_name, _options -> + raise "Finch was unable to provide a connection within the timeout due to excess queuing" + end + + capture_io(:stderr, fn -> + assert {:error, :timeout} = + ReqOpenAIValidationClient.get_models("pool-timeout-secret", + finch_request: finch_request + ) + end) + end + + test "does not retry provider failures" do + counter = start_supervised!({Agent, fn -> 0 end}) + + plug = fn conn -> + Agent.update(counter, &(&1 + 1)) + + conn + |> Plug.Conn.put_status(503) + |> Req.Test.json(%{"error" => "unavailable"}) + end + + assert {:ok, 503, %{"error" => "unavailable"}} = + ReqOpenAIValidationClient.get_models("no-retry-secret", plug: plug) + + assert Agent.get(counter, & &1) == 1 + end +end diff --git a/test/kodo_web/controllers/user_session_controller_test.exs b/test/kodo_web/controllers/user_session_controller_test.exs index dd96e2f..d199fa9 100644 --- a/test/kodo_web/controllers/user_session_controller_test.exs +++ b/test/kodo_web/controllers/user_session_controller_test.exs @@ -72,6 +72,31 @@ defmodule KodoWeb.UserSessionControllerTest do end end + describe "GET /users/reauthenticate/:provider/:action" do + test "preserves only the allowlisted OpenAI action for reauthentication", %{ + conn: conn, + user: user + } do + conn = + conn + |> log_in_user(user) + |> get(~p"/users/reauthenticate/openai/replace") + + assert redirected_to(conn) == ~p"/users/log-in" + assert get_session(conn, :user_return_to) == ~p"/integrations?action=replace" + end + + test "rejects unsupported providers and actions", %{conn: conn, user: user} do + conn = + conn + |> log_in_user(user) + |> get(~p"/users/reauthenticate/other/reveal") + + assert redirected_to(conn) == ~p"/integrations" + refute get_session(conn, :user_return_to) + end + end + describe "POST /users/log-in - magic link" do test "logs the user in", %{conn: conn, user: user} do {token, _hashed_token} = generate_user_magic_link_token(user) diff --git a/test/kodo_web/live/integrations_live_test.exs b/test/kodo_web/live/integrations_live_test.exs new file mode 100644 index 0000000..b517618 --- /dev/null +++ b/test/kodo_web/live/integrations_live_test.exs @@ -0,0 +1,376 @@ +defmodule KodoWeb.IntegrationsLiveTest do + use KodoWeb.ConnCase, async: false + + import Kodo.AccountsFixtures + import Phoenix.LiveViewTest + + alias Kodo.Integrations + alias Kodo.Integrations.CredentialEncryption + + setup %{conn: conn} do + user = user_fixture() + %{conn: log_in_user(conn, user), scope: Kodo.Accounts.Scope.for_user(user), user: user} + end + + test "renders the authenticated settings shell and disconnected OpenAI card", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/integrations") + + assert has_element?(view, "#settings-shell") + assert has_element?(view, "#settings-nav-integrations[aria-current='page']") + assert has_element?(view, "#settings-nav-account") + assert has_element?(view, "#openai-integration #openai-connect") + assert has_element?(view, "#openai-status", "Not connected") + refute has_element?(view, "#openai-status", "Validation") + assert has_element?(view, "#openai-status[aria-live='polite'][aria-atomic='true']") + end + + test "requires authentication", %{conn: _conn} do + assert {:error, {:redirect, %{to: "/users/log-in"}}} = + build_conn() |> live(~p"/integrations") + end + + test "requires fresh sudo before displaying the API-key form", %{user: user} do + conn = + build_conn() + |> log_in_user(user, + token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute) + ) + + assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/integrations?action=connect") + assert path == ~p"/users/reauthenticate/openai/connect" + end + + test "connects without assigning or rendering the submitted key", %{conn: conn, scope: scope} do + Phoenix.PubSub.subscribe(Kodo.PubSub, "integration:#{scope.user.id}") + {:ok, view, _html} = live(conn, ~p"/integrations?action=connect") + + assert has_element?(view, "#openai-api-key-form") + refute has_element?(view, "#openai-api-key-form[phx-change]") + + secret = "openai-live-secret" + + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => secret}}) + |> render_submit() + + assert_receive message = {:integration_validation_finished, _id, _generation} + send(view.pid, message) + _ = :sys.get_state(view.pid) + refute render(view) =~ secret + refute inspect(:sys.get_state(view.pid)) =~ secret + refute has_element?(view, "#openai-api-key-panel") + assert has_element?(view, "#openai-status", "Connected") + assert has_element?(view, "#openai-status", "Validation unavailable") + + assert {:ok, integration} = Integrations.get_integration_by_provider(scope, "openai") + assert {:ok, %{"api_key" => ^secret}} = CredentialEncryption.decrypt(integration) + end + + test "updates the displayed status after asynchronous validation", %{conn: conn, scope: scope} do + Phoenix.PubSub.subscribe(Kodo.PubSub, "integration:#{scope.user.id}") + {:ok, view, _html} = live(conn, ~p"/integrations?action=connect") + + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => "valid-live-secret"}}) + |> render_submit() + + assert_receive message = {:integration_validation_finished, _id, _generation} + send(view.pid, message) + _ = :sys.get_state(view.pid) + assert has_element?(view, "#openai-status", "Validated") + refute has_element?(view, "#openai-validation-progress") + end + + test "replaces a key and clears the form after success", %{conn: conn, scope: scope} do + {:ok, original} = connect_openai(scope, "first-secret") + {:ok, view, _html} = live(conn, ~p"/integrations?action=replace") + + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => "replacement-secret"}}) + |> render_submit() + + assert {:ok, replaced} = Integrations.get_integration_by_provider(scope, "openai") + assert replaced.credential_generation == original.credential_generation + 1 + + assert {:ok, %{"api_key" => "replacement-secret"}} = + CredentialEncryption.decrypt(replaced) + + refute has_element?(view, "#openai-api-key-panel") + end + + test "reconnects a disconnected integration through the connect action", %{ + conn: conn, + scope: scope + } do + {:ok, original} = connect_openai(scope, "first-secret") + + {:ok, disconnected} = + Integrations.disconnect(scope, original.id, original.credential_generation) + + {:ok, view, _html} = live(conn, ~p"/integrations?action=connect") + + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => "valid-reconnected"}}) + |> render_submit() + + assert {:ok, reconnected} = Integrations.get_integration(scope, original.id) + assert reconnected.connection_status == "connected" + assert reconnected.credential_generation == disconnected.credential_generation + 1 + + assert {:ok, %{"api_key" => "valid-reconnected"}} = + CredentialEncryption.decrypt(reconnected) + end + + test "generation-fences a stale replacement form", %{conn: conn, scope: scope} do + {:ok, original} = connect_openai(scope, "first-secret") + {:ok, view, _html} = live(conn, ~p"/integrations?action=replace") + + assert {:ok, current} = + Integrations.replace_credentials( + scope, + original.id, + original.credential_generation, + %{"api_key" => "concurrent-secret"} + ) + + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => "stale-secret"}}) + |> render_submit() + + assert {:ok, persisted} = Integrations.get_integration_by_provider(scope, "openai") + assert persisted.credential_generation == current.credential_generation + + assert {:ok, %{"api_key" => "concurrent-secret"}} = + CredentialEncryption.decrypt(persisted) + end + + test "does not adopt a replacement generation after a validation refresh", %{ + conn: conn, + scope: scope + } do + {:ok, original} = connect_openai(scope, "first-secret") + {:ok, view, _html} = live(conn, ~p"/integrations?action=replace") + + {:ok, current} = + Integrations.replace_credentials( + scope, + original.id, + original.credential_generation, + %{"api_key" => "concurrent-secret"} + ) + + send( + view.pid, + {:integration_validation_finished, current.id, current.credential_generation} + ) + + _ = :sys.get_state(view.pid) + + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => "stale-secret"}}) + |> render_submit() + + assert {:ok, persisted} = Integrations.get_integration(scope, original.id) + assert persisted.credential_generation == current.credential_generation + + assert {:ok, %{"api_key" => "concurrent-secret"}} = + CredentialEncryption.decrypt(persisted) + end + + test "does not turn an open connect form into replacement after another tab connects", %{ + conn: conn, + scope: scope + } do + {:ok, view, _html} = live(conn, ~p"/integrations?action=connect") + {:ok, current} = connect_openai(scope, "concurrent-secret") + + send( + view.pid, + {:integration_validation_finished, current.id, current.credential_generation} + ) + + _ = :sys.get_state(view.pid) + + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => "stale-secret"}}) + |> render_submit() + + assert {:ok, persisted} = Integrations.get_integration(scope, current.id) + + assert {:ok, %{"api_key" => "concurrent-secret"}} = + CredentialEncryption.decrypt(persisted) + end + + test "does not disconnect a newer credential after a validation refresh", %{ + conn: conn, + scope: scope + } do + {:ok, original} = connect_openai(scope, "first-secret") + {:ok, view, _html} = live(conn, ~p"/integrations?action=disconnect") + + {:ok, current} = + Integrations.replace_credentials( + scope, + original.id, + original.credential_generation, + %{"api_key" => "concurrent-secret"} + ) + + send( + view.pid, + {:integration_validation_finished, current.id, current.credential_generation} + ) + + _ = :sys.get_state(view.pid) + view |> element("#openai-confirm-disconnect") |> render_click() + + assert {:ok, persisted} = Integrations.get_integration(scope, original.id) + assert persisted.connection_status == "connected" + assert persisted.credential_generation == current.credential_generation + end + + test "remains alive when an older validation finishes after a newer task", %{ + conn: conn + } do + Application.put_env(:kodo, :fake_openai_validation_test_pid, self()) + + on_exit(fn -> Application.delete_env(:kodo, :fake_openai_validation_test_pid) end) + + {:ok, view, _html} = live(conn, ~p"/integrations?action=connect") + + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => "blocking-first"}}) + |> render_submit() + + assert_receive {:validation_probe_started, first_probe, first_validation} + render_patch(view, ~p"/integrations?action=replace") + + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => "valid-second"}}) + |> render_submit() + + send(first_probe, {:finish_validation_probe, {:ok, 200, %{"data" => []}}}) + first_validation_ref = Process.monitor(first_validation) + view_ref = Process.monitor(view.pid) + assert_receive {:DOWN, ^first_validation_ref, :process, ^first_validation, _reason} + _ = :sys.get_state(view.pid) + refute_receive {:DOWN, ^view_ref, :process, _, _reason} + end + + test "does not show obsolete validation progress for the current generation", %{ + conn: conn, + scope: scope + } do + Application.put_env(:kodo, :fake_openai_validation_test_pid, self()) + + on_exit(fn -> Application.delete_env(:kodo, :fake_openai_validation_test_pid) end) + Phoenix.PubSub.subscribe(Kodo.PubSub, "integration:#{scope.user.id}") + + {:ok, view, _html} = live(conn, ~p"/integrations?action=connect") + + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => "blocking-first"}}) + |> render_submit() + + assert_receive {:validation_probe_started, first_probe, first_validation} + render_patch(view, ~p"/integrations?action=replace") + + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => "blocking-second"}}) + |> render_submit() + + assert_receive {:validation_probe_started, second_probe, _second_validation} + send(second_probe, {:finish_validation_probe, {:ok, 200, %{"data" => []}}}) + assert_receive message = {:integration_validation_finished, _id, _generation} + send(view.pid, message) + _ = :sys.get_state(view.pid) + + assert has_element?(view, "#openai-status", "Validated") + refute has_element?(view, "#openai-validation-progress") + + first_validation_ref = Process.monitor(first_validation) + send(first_probe, {:finish_validation_probe, {:ok, 200, %{"data" => []}}}) + assert_receive {:DOWN, ^first_validation_ref, :process, ^first_validation, _reason} + end + + test "requires fresh sudo again when submitting without retaining the key", %{ + conn: conn, + scope: scope + } do + {:ok, view, _html} = live(conn, ~p"/integrations?action=connect") + expire_sudo(view) + secret = "expired-sudo-secret" + + response = + view + |> form("#openai-api-key-form", %{"integration" => %{"api_key" => secret}}) + |> render_submit() + + assert_redirect(view, ~p"/users/reauthenticate/openai/connect") + refute inspect(response) =~ secret + + assert {:error, :integration_not_found} = + Integrations.get_integration_by_provider(scope, "openai") + end + + test "requires fresh sudo again before disconnecting", %{conn: conn, scope: scope} do + {:ok, integration} = connect_openai(scope, "disconnect-secret") + {:ok, view, _html} = live(conn, ~p"/integrations?action=disconnect") + expire_sudo(view) + + view |> element("#openai-confirm-disconnect") |> render_click() + + assert_redirect(view, ~p"/users/reauthenticate/openai/disconnect") + assert {:ok, persisted} = Integrations.get_integration(scope, integration.id) + assert persisted.connection_status == "connected" + end + + test "disconnect confirmation explains admitted requests and clears credentials", %{ + conn: conn, + scope: scope + } do + {:ok, _integration} = connect_openai(scope, "disconnect-secret") + {:ok, view, _html} = live(conn, ~p"/integrations?action=disconnect") + + assert has_element?(view, "#openai-disconnect-panel", "already admitted or sent") + + assert has_element?( + view, + "#openai-revoke-key-link[href='https://platform.openai.com/api-keys'][target='_blank']" + ) + + assert has_element?(view, "#openai-revoke-key-link .sr-only", "opens in a new tab") + + view |> element("#openai-confirm-disconnect") |> render_click() + + assert {:ok, disconnected} = Integrations.get_integration_by_provider(scope, "openai") + assert disconnected.connection_status == "disconnected" + assert is_nil(disconnected.encrypted_credentials) + refute has_element?(view, "#openai-disconnect-panel") + end + + test "does not show validation state for a disconnected row", %{conn: conn, scope: scope} do + {:ok, integration} = connect_openai(scope, "disconnected-secret") + + {:ok, _integration} = + Integrations.disconnect(scope, integration.id, integration.credential_generation) + + {:ok, view, _html} = live(conn, ~p"/integrations") + + assert has_element?(view, "#openai-status", "Disconnected") + refute has_element?(view, "#openai-status", "Validation") + end + + defp connect_openai(scope, key) do + Integrations.connect(scope, "openai", "api_key", %{"api_key" => key}) + end + + defp expire_sudo(view) do + :sys.replace_state(view.pid, fn state -> + put_in( + state.socket.assigns.current_scope.user.authenticated_at, + DateTime.add(DateTime.utc_now(:second), -11, :minute) + ) + end) + end +end diff --git a/test/kodo_web/live/user_live/settings_test.exs b/test/kodo_web/live/user_live/settings_test.exs index ab7ca8a..51875e9 100644 --- a/test/kodo_web/live/user_live/settings_test.exs +++ b/test/kodo_web/live/user_live/settings_test.exs @@ -7,13 +7,18 @@ defmodule KodoWeb.UserLive.SettingsTest do describe "Settings page" do test "renders settings page", %{conn: conn} do - {:ok, _lv, html} = + {:ok, lv, _html} = conn |> log_in_user(user_fixture()) |> live(~p"/users/settings") - assert html =~ "Change Email" - assert html =~ "Save Password" + assert has_element?(lv, "#settings-shell") + assert has_element?(lv, "#settings-sections[aria-label='Settings sections']") + assert has_element?(lv, "#settings-nav-account[aria-current='page']") + assert has_element?(lv, "#settings-nav-integrations[href='/integrations']") + assert has_element?(lv, "#settings-return[href='/sessions']") + assert has_element?(lv, "#settings-detail #email-settings #email_form") + assert has_element?(lv, "#settings-detail #password-settings #password_form") end test "redirects if user is not logged in", %{conn: conn} do diff --git a/test/support/fake_openai_validation_client.ex b/test/support/fake_openai_validation_client.ex new file mode 100644 index 0000000..e9fff86 --- /dev/null +++ b/test/support/fake_openai_validation_client.ex @@ -0,0 +1,39 @@ +defmodule Kodo.Test.FakeOpenAIValidationClient do + @moduledoc false + + @behaviour Kodo.Integrations.OpenAIValidationClient + + @impl true + def get_models("valid-" <> _rest), do: {:ok, 200, %{"data" => []}} + + def get_models("invalid-" <> _rest), + do: {:ok, 401, %{"error" => %{"code" => "invalid_api_key"}}} + + def get_models("revoked-" <> _rest), + do: {:ok, 401, %{"error" => %{"code" => "key_revoked"}}} + + def get_models("permission-" <> _rest), + do: {:ok, 401, %{"error" => %{"code" => "organization_restricted"}}} + + def get_models("timeout-" <> _rest), do: {:error, :timeout} + def get_models("tls-" <> _rest), do: {:error, :tls_error} + def get_models("redirect-" <> _rest), do: {:error, :redirect} + def get_models("rate-limited-" <> _rest), do: {:ok, 429, %{}} + def get_models("provider-error-" <> _rest), do: {:ok, 503, %{}} + + def get_models("raising-" <> secret) do + raise "validation client exposed #{secret}" + end + + def get_models("blocking-" <> _rest) do + test_pid = Application.fetch_env!(:kodo, :fake_openai_validation_test_pid) + [caller | _callers] = Process.get(:"$callers") + send(test_pid, {:validation_probe_started, self(), caller}) + + receive do + {:finish_validation_probe, result} -> result + end + end + + def get_models(_api_key), do: {:error, :network_error} +end