From ee036e9b1074f3e3ecfca586ee450a9ac9c4e7d7 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Sat, 14 Mar 2026 13:07:37 +0000 Subject: [PATCH 1/6] feat: add demo OAuth client setup for Google, Salesforce, and Microsoft Add mix task and public API for creating pre-configured global OAuth clients. Supports --list to show config status, --only to select specific clients, and --email to specify the owner. Reads credentials from app config (Bootstrap/Dotenvy), skips unconfigured clients gracefully, and is fully idempotent. --- lib/lightning/config/bootstrap.ex | 46 +++ lib/lightning/setup_utils.ex | 351 ++++++++++++++++++++++ lib/mix/tasks/setup_demo_oauth_clients.ex | 239 +++++++++++++++ test/lightning/setup_utils_test.exs | 256 +++++++++++++++- 4 files changed, 891 insertions(+), 1 deletion(-) create mode 100644 lib/mix/tasks/setup_demo_oauth_clients.ex diff --git a/lib/lightning/config/bootstrap.ex b/lib/lightning/config/bootstrap.ex index 11df8cadbe..3f48f2d819 100644 --- a/lib/lightning/config/bootstrap.ex +++ b/lib/lightning/config/bootstrap.ex @@ -744,6 +744,8 @@ defmodule Lightning.Config.Bootstrap do "lightning-cluster" ) + setup_demo_oauth_clients() + # ============================================================================== setup_storage() @@ -964,4 +966,48 @@ defmodule Lightning.Config.Bootstrap do You can generate new worker keys using: mix lightning.gen_worker_keys """ end + + defp setup_demo_oauth_clients do + config :lightning, :demo_oauth_clients, + google_drive: [ + client_id: env!("GOOGLE_DRIVE_CLIENT_ID", :string, nil), + client_secret: env!("GOOGLE_DRIVE_CLIENT_SECRET", :string, nil) + ], + google_sheets: [ + client_id: env!("GOOGLE_SHEETS_CLIENT_ID", :string, nil), + client_secret: env!("GOOGLE_SHEETS_CLIENT_SECRET", :string, nil) + ], + gmail: [ + client_id: env!("GMAIL_CLIENT_ID", :string, nil), + client_secret: env!("GMAIL_CLIENT_SECRET", :string, nil) + ], + salesforce: [ + client_id: env!("SALESFORCE_CLIENT_ID", :string, nil), + client_secret: env!("SALESFORCE_CLIENT_SECRET", :string, nil) + ], + salesforce_sandbox: [ + client_id: env!("SALESFORCE_SANDBOX_CLIENT_ID", :string, nil), + client_secret: env!("SALESFORCE_SANDBOX_CLIENT_SECRET", :string, nil) + ], + microsoft_sharepoint: [ + client_id: env!("MICROSOFT_SHAREPOINT_CLIENT_ID", :string, nil), + client_secret: env!("MICROSOFT_SHAREPOINT_CLIENT_SECRET", :string, nil) + ], + microsoft_outlook: [ + client_id: env!("MICROSOFT_OUTLOOK_CLIENT_ID", :string, nil), + client_secret: env!("MICROSOFT_OUTLOOK_CLIENT_SECRET", :string, nil) + ], + microsoft_calendar: [ + client_id: env!("MICROSOFT_CALENDAR_CLIENT_ID", :string, nil), + client_secret: env!("MICROSOFT_CALENDAR_CLIENT_SECRET", :string, nil) + ], + microsoft_onedrive: [ + client_id: env!("MICROSOFT_ONEDRIVE_CLIENT_ID", :string, nil), + client_secret: env!("MICROSOFT_ONEDRIVE_CLIENT_SECRET", :string, nil) + ], + microsoft_teams: [ + client_id: env!("MICROSOFT_TEAMS_CLIENT_ID", :string, nil), + client_secret: env!("MICROSOFT_TEAMS_CLIENT_SECRET", :string, nil) + ] + end end diff --git a/lib/lightning/setup_utils.ex b/lib/lightning/setup_utils.ex index 86d57d498a..cc9439f15b 100644 --- a/lib/lightning/setup_utils.ex +++ b/lib/lightning/setup_utils.ex @@ -9,6 +9,7 @@ defmodule Lightning.SetupUtils do alias Lightning.Accounts.User alias Lightning.Credentials alias Lightning.Jobs + alias Lightning.OauthClients alias Lightning.Projects alias Lightning.Repo alias Lightning.Runs @@ -38,6 +39,62 @@ defmodule Lightning.SetupUtils do end end + @doc """ + Creates demo OAuth clients for an existing user. + + This function can be called independently without resetting the database. + It will skip creating OAuth clients that already exist (by name). + + ## Options + - `:user_email` - Email of the user who will own the OAuth clients. + If not provided, uses the first superuser found, or falls back to + the first user in the system. + - `:only` - List of client keys to create. If omitted, creates all clients. + Available keys: `:google_drive`, `:google_sheets`, `:gmail`, `:salesforce`, + `:salesforce_sandbox`, `:microsoft_sharepoint`, `:microsoft_outlook`, + `:microsoft_calendar`, `:microsoft_onedrive`, `:microsoft_teams` + + ## Examples + + # Create all clients + Lightning.SetupUtils.setup_demo_oauth_clients() + + # Create only specific clients + Lightning.SetupUtils.setup_demo_oauth_clients(only: [:google_sheets, :salesforce]) + + # Specify a user by email + Lightning.SetupUtils.setup_demo_oauth_clients(user_email: "admin@example.com") + """ + def setup_demo_oauth_clients(opts \\ []) do + case find_oauth_client_owner(opts[:user_email]) do + {:ok, user} -> + {:ok, create_demo_oauth_clients(user, opts)} + + {:error, reason} -> + {:error, reason} + end + end + + defp find_oauth_client_owner(nil) do + case Repo.one(from u in User, where: u.role == :superuser, limit: 1) do + nil -> + case Repo.one(from u in User, limit: 1) do + nil -> {:error, :no_users_found} + user -> {:ok, user} + end + + user -> + {:ok, user} + end + end + + defp find_oauth_client_owner(email) when is_binary(email) do + case Accounts.get_user_by_email(email) do + nil -> {:error, :user_not_found} + user -> {:ok, user} + end + end + @spec setup_demo(nil | maybe_improper_list | map) :: %{ jobs: [...], projects: [atom | %{:id => any, optional(any) => any}, ...], @@ -115,6 +172,300 @@ defmodule Lightning.SetupUtils do credential end + @doc """ + Creates demo OAuth clients for Google (Drive, Sheets, Gmail), Salesforce, and Microsoft + (SharePoint, Outlook, Calendar, OneDrive, Teams). + + Idempotent — skips any clients that already exist (by name). + Clients without configured environment variables are returned as `:not_configured`. + + Client IDs and secrets are read from application configuration + (set via environment variables). + + ## Parameters + - user: The `%User{}` who will own the OAuth clients. + - opts: Keyword list with options + - `:only` - List of client keys to create (e.g., `[:google_sheets, :salesforce]`). + If omitted, creates all configured clients. When `:only` is specified, + raises if any requested clients are missing configuration. + + ## Returns + A map of `%{atom => %OauthClient{} | :skipped | :not_configured}`. + """ + def create_demo_oauth_clients(%Accounts.User{} = user, opts \\ []) do + create_demo_oauth_clients_impl(user.id, opts[:only]) + end + + @doc """ + Returns a list of all available demo OAuth client keys and their configuration status. + + ## Examples + + Lightning.SetupUtils.list_demo_oauth_clients() + #=> [ + #=> {:google_drive, :configured}, + #=> {:google_sheets, :not_configured}, + #=> ... + #=> ] + """ + def list_demo_oauth_clients do + all_keys() + |> Enum.map(fn key -> + case get_oauth_credentials(key) do + {_id, _secret} -> {key, :configured} + :not_configured -> {key, :not_configured} + end + end) + end + + @doc """ + Returns the list of all available demo OAuth client keys. + """ + def all_keys do + ~w( + google_drive google_sheets gmail + salesforce salesforce_sandbox + microsoft_sharepoint microsoft_outlook microsoft_calendar + microsoft_onedrive microsoft_teams + )a + end + + @doc """ + Returns the environment variable names needed for a given client key. + """ + def env_vars_for(key) do + prefix = key |> Atom.to_string() |> String.upcase() + {"#{prefix}_CLIENT_ID", "#{prefix}_CLIENT_SECRET"} + end + + defp create_demo_oauth_clients_impl(user_id, only) do + existing_names = + from(c in Lightning.Credentials.OauthClient, select: c.name) + |> Repo.all() + |> MapSet.new() + + definitions = demo_oauth_client_definitions(user_id) + + definitions = + if only do + Enum.filter(definitions, fn {key, _} -> key in only end) + else + definitions + end + + if only do + validate_credentials_configured!(definitions) + end + + Enum.reduce(definitions, %{}, fn {key, client_attrs}, acc -> + cond do + client_attrs == :not_configured -> + Map.put(acc, key, :not_configured) + + MapSet.member?(existing_names, client_attrs.name) -> + Map.put(acc, key, :skipped) + + true -> + case OauthClients.create_client(client_attrs) do + {:ok, client} -> + Map.put(acc, key, client) + + {:error, changeset} -> + raise "Failed to create OAuth client #{key}: #{inspect(changeset.errors)}" + end + end + end) + end + + defp validate_credentials_configured!(definitions) do + missing = + definitions + |> Enum.filter(fn {_key, attrs} -> attrs == :not_configured end) + |> Enum.map(fn {key, _} -> key end) + + if missing != [] do + env_var_hints = + Enum.map_join(missing, "\n", fn key -> + {id_var, secret_var} = env_vars_for(key) + " #{id_var}\n #{secret_var}" + end) + + raise """ + Missing OAuth client configuration for: #{Enum.join(missing, ", ")} + + Set the following environment variables (in .env or system environment): + + #{env_var_hints} + """ + end + end + + @google_scopes_base "openid,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile" + + defp demo_oauth_client_definitions(user_id) do + [ + # Google services + {:google_drive, + google_oauth_client( + "Google Drive", + :google_drive, + "#{@google_scopes_base},https://www.googleapis.com/auth/drive", + user_id + )}, + {:google_sheets, + google_oauth_client( + "Google Sheets", + :google_sheets, + "#{@google_scopes_base},https://www.googleapis.com/auth/spreadsheets", + user_id + )}, + {:gmail, + google_oauth_client( + "Gmail", + :gmail, + "#{@google_scopes_base},https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/gmail.send", + user_id + )}, + # Salesforce + {:salesforce, + salesforce_oauth_client( + "Salesforce", + :salesforce, + "login.salesforce.com", + user_id + )}, + {:salesforce_sandbox, + salesforce_oauth_client( + "Salesforce Sandbox", + :salesforce_sandbox, + "test.salesforce.com", + user_id + )}, + # Microsoft services + {:microsoft_sharepoint, + microsoft_oauth_client( + "Microsoft SharePoint", + :microsoft_sharepoint, + "openid,email,profile,offline_access,Sites.Read.All,Sites.ReadWrite.All", + user_id + )}, + {:microsoft_outlook, + microsoft_oauth_client( + "Microsoft Outlook", + :microsoft_outlook, + "openid,email,profile,offline_access,Mail.Read,Mail.Send", + user_id + )}, + {:microsoft_calendar, + microsoft_oauth_client( + "Microsoft Calendar", + :microsoft_calendar, + "openid,email,profile,offline_access,Calendars.Read,Calendars.ReadWrite", + user_id + )}, + {:microsoft_onedrive, + microsoft_oauth_client( + "Microsoft OneDrive", + :microsoft_onedrive, + "openid,email,profile,offline_access,Files.Read,Files.ReadWrite", + user_id + )}, + {:microsoft_teams, + microsoft_oauth_client( + "Microsoft Teams", + :microsoft_teams, + "openid,email,profile,offline_access,Team.ReadBasic.All,Channel.ReadBasic.All,Chat.Read", + user_id + )} + ] + end + + defp google_oauth_client(name, config_key, mandatory_scopes, user_id) do + case get_oauth_credentials(config_key) do + {client_id, client_secret} -> + %{ + name: name, + client_id: client_id, + client_secret: client_secret, + authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth", + token_endpoint: "https://oauth2.googleapis.com/token", + revocation_endpoint: "https://oauth2.googleapis.com/revoke", + userinfo_endpoint: "https://openidconnect.googleapis.com/v1/userinfo", + scopes_doc_url: + "https://developers.google.com/identity/protocols/oauth2/scopes", + mandatory_scopes: mandatory_scopes, + global: true, + user_id: user_id + } + + :not_configured -> + :not_configured + end + end + + defp salesforce_oauth_client(name, config_key, domain, user_id) do + case get_oauth_credentials(config_key) do + {client_id, client_secret} -> + %{ + name: name, + client_id: client_id, + client_secret: client_secret, + authorization_endpoint: "https://#{domain}/services/oauth2/authorize", + token_endpoint: "https://#{domain}/services/oauth2/token", + revocation_endpoint: "https://#{domain}/services/oauth2/revoke", + userinfo_endpoint: "https://#{domain}/services/oauth2/userinfo", + scopes_doc_url: + "https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_tokens_scopes.htm", + mandatory_scopes: "openid,api,refresh_token,full", + global: true, + user_id: user_id + } + + :not_configured -> + :not_configured + end + end + + defp microsoft_oauth_client(name, config_key, mandatory_scopes, user_id) do + case get_oauth_credentials(config_key) do + {client_id, client_secret} -> + %{ + name: name, + client_id: client_id, + client_secret: client_secret, + authorization_endpoint: + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + token_endpoint: + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + revocation_endpoint: + "https://login.microsoftonline.com/common/oauth2/v2.0/logout", + userinfo_endpoint: "https://graph.microsoft.com/oidc/userinfo", + scopes_doc_url: + "https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc", + mandatory_scopes: mandatory_scopes, + global: true, + user_id: user_id + } + + :not_configured -> + :not_configured + end + end + + defp get_oauth_credentials(config_key) do + config = Application.get_env(:lightning, :demo_oauth_clients, []) + client_config = Keyword.get(config, config_key, []) + + client_id = Keyword.get(client_config, :client_id) + client_secret = Keyword.get(client_config, :client_secret) + + if client_id && client_secret do + {client_id, client_secret} + else + :not_configured + end + end + def create_users(opts) do super_user = if opts[:create_super] do diff --git a/lib/mix/tasks/setup_demo_oauth_clients.ex b/lib/mix/tasks/setup_demo_oauth_clients.ex new file mode 100644 index 0000000000..aa48aae389 --- /dev/null +++ b/lib/mix/tasks/setup_demo_oauth_clients.ex @@ -0,0 +1,239 @@ +defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do + @shortdoc "Set up demo OAuth clients for Google, Salesforce, and Microsoft" + + @moduledoc """ + Sets up demo OAuth clients for Google, Salesforce, and Microsoft services. + + This task creates global OAuth clients that can be used across all projects. + It will skip any OAuth clients that already exist (by name). + + ## Usage + + mix lightning.setup_demo_oauth_clients [OPTIONS] + + ## Options + + * `--email` - Email of the user who will own the OAuth clients. + If not provided, uses the first superuser found, or falls back to + the first user in the system. + + * `--only` - Comma-separated list of client keys to create. + If omitted, creates all configured clients. + + * `--list` - Show available clients and their configuration status, + then exit without creating anything. + + ## Available client keys + + google_drive, google_sheets, gmail, + salesforce, salesforce_sandbox, + microsoft_sharepoint, microsoft_outlook, microsoft_calendar, + microsoft_onedrive, microsoft_teams + + ## Environment Variables + + Each service requires two environment variables (set in `.env`): + + - `{SERVICE}_CLIENT_ID` - The OAuth client ID + - `{SERVICE}_CLIENT_SECRET` - The OAuth client secret + + Where `{SERVICE}` matches the uppercased client key + (e.g., `GOOGLE_SHEETS_CLIENT_ID` for `google_sheets`). + + ## Examples + + # Show what's available and what's configured + mix lightning.setup_demo_oauth_clients --list + + # Create all configured OAuth clients + mix lightning.setup_demo_oauth_clients + + # Create only Google Sheets and Salesforce + mix lightning.setup_demo_oauth_clients --only google_sheets,salesforce + + # Specify a user by email + mix lightning.setup_demo_oauth_clients --email admin@example.com --only gmail + """ + + use Mix.Task + + alias Lightning.SetupUtils + + @valid_keys SetupUtils.all_keys() + + @impl Mix.Task + def run(args) do + {opts, _, invalid} = + OptionParser.parse(args, + strict: [email: :string, only: :string, list: :boolean], + aliases: [e: :email, o: :only, l: :list] + ) + + if invalid != [] do + invalid_opts = Enum.map_join(invalid, ", ", fn {opt, _} -> opt end) + + Mix.raise(""" + Unknown option(s): #{invalid_opts} + + Run `mix help lightning.setup_demo_oauth_clients` for more information. + """) + end + + Mix.Task.run("app.start") + + if opts[:list] do + print_available_clients() + else + create_clients(opts) + end + end + + defp print_available_clients do + clients = SetupUtils.list_demo_oauth_clients() + + configured = + Enum.filter(clients, fn {_, status} -> status == :configured end) + + not_configured = + Enum.filter(clients, fn {_, status} -> status == :not_configured end) + + Mix.shell().info("\nAvailable demo OAuth clients:\n") + + if configured != [] do + Mix.shell().info(" Ready to create:") + + Enum.each(configured, fn {key, _} -> + Mix.shell().info(" ✓ #{key}") + end) + + Mix.shell().info("") + end + + if not_configured != [] do + Mix.shell().info(" Missing OAuth client configuration:") + + Enum.each(not_configured, fn {key, _} -> + {id_var, secret_var} = SetupUtils.env_vars_for(key) + Mix.shell().info(" ✗ #{key}") + Mix.shell().info(" #{id_var}") + Mix.shell().info(" #{secret_var}") + end) + + Mix.shell().info("") + end + + Mix.shell().info( + " #{length(configured)} configured, #{length(not_configured)} missing configuration\n" + ) + end + + defp create_clients(opts) do + only = parse_only(opts[:only]) + + setup_opts = + Enum.reject( + [user_email: opts[:email], only: only], + fn {_k, v} -> is_nil(v) end + ) + + case SetupUtils.setup_demo_oauth_clients(setup_opts) do + {:ok, results} -> + print_results(results) + + {:error, :no_users_found} -> + Mix.raise(""" + No users found in the database. + + Please create at least one user before running this task: + mix run -e 'Lightning.SetupUtils.setup_demo()' + """) + + {:error, :user_not_found} -> + Mix.raise(""" + User not found with email: #{opts[:email]} + + Please check the email address and try again. + """) + end + end + + defp parse_only(nil), do: nil + + defp parse_only(value) do + valid_strings = Enum.map(@valid_keys, &Atom.to_string/1) + + strings = + value + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + + invalid = strings -- valid_strings + + if invalid != [] do + Mix.raise(""" + Unknown client key(s): #{Enum.join(invalid, ", ")} + + Available keys: #{Enum.join(@valid_keys, ", ")} + """) + end + + Enum.map(strings, &String.to_existing_atom/1) + end + + defp print_results(results) do + created = + Enum.filter(results, fn {_, v} -> not is_atom(v) end) + + skipped = + Enum.filter(results, fn {_, v} -> v == :skipped end) + + not_configured = + Enum.filter(results, fn {_, v} -> v == :not_configured end) + + if created != [] do + Mix.shell().info("\nCreated:") + + Enum.each(created, fn {key, client} -> + Mix.shell().info(" ✓ #{client.name} (#{key})") + end) + end + + if skipped != [] do + Mix.shell().info("\nAlready exist:") + + Enum.each(skipped, fn {key, _} -> + Mix.shell().info(" - #{format_key(key)}") + end) + end + + if not_configured != [] do + Mix.shell().info("\nMissing OAuth client configuration:") + + Enum.each(not_configured, fn {key, _} -> + {id_var, secret_var} = SetupUtils.env_vars_for(key) + Mix.shell().info(" ✗ #{format_key(key)} (set #{id_var}, #{secret_var})") + end) + end + + if created == [] and not_configured != [] and skipped == [] do + Mix.shell().info(""" + + No clients were created. Set the CLIENT_ID and CLIENT_SECRET environment variables for the + services you need, then run this task again. Use --list to see all options. + """) + else + Mix.shell().info( + "\nDone! Created #{length(created)}, skipped #{length(skipped)}, not configured #{length(not_configured)}." + ) + end + end + + defp format_key(key) do + key + |> Atom.to_string() + |> String.replace("_", " ") + |> String.split() + |> Enum.map(&String.capitalize/1) + |> Enum.join(" ") + end +end diff --git a/test/lightning/setup_utils_test.exs b/test/lightning/setup_utils_test.exs index 2921608419..07f8ce1b4f 100644 --- a/test/lightning/setup_utils_test.exs +++ b/test/lightning/setup_utils_test.exs @@ -1,6 +1,6 @@ defmodule Lightning.SetupUtilsTest do alias Lightning.Invocation - use Lightning.DataCase, async: true + use Lightning.DataCase, async: false import Swoosh.TestAssertions alias Lightning.{Accounts, Projects, Workflows, Jobs, SetupUtils} @@ -8,6 +8,260 @@ defmodule Lightning.SetupUtilsTest do alias Lightning.Accounts.{User, UserToken} alias Lightning.Credentials.{Credential} + @test_oauth_config [ + google_drive: [client_id: "test-gd-id", client_secret: "test-gd-secret"], + google_sheets: [client_id: "test-gs-id", client_secret: "test-gs-secret"], + gmail: [client_id: "test-gm-id", client_secret: "test-gm-secret"], + salesforce: [client_id: "test-sf-id", client_secret: "test-sf-secret"], + salesforce_sandbox: [ + client_id: "test-sfs-id", + client_secret: "test-sfs-secret" + ], + microsoft_sharepoint: [ + client_id: "test-sp-id", + client_secret: "test-sp-secret" + ], + microsoft_outlook: [ + client_id: "test-ol-id", + client_secret: "test-ol-secret" + ], + microsoft_calendar: [ + client_id: "test-cal-id", + client_secret: "test-cal-secret" + ], + microsoft_onedrive: [ + client_id: "test-od-id", + client_secret: "test-od-secret" + ], + microsoft_teams: [ + client_id: "test-tm-id", + client_secret: "test-tm-secret" + ] + ] + + defp with_oauth_config(config \\ @test_oauth_config) do + previous = Application.get_env(:lightning, :demo_oauth_clients) + Application.put_env(:lightning, :demo_oauth_clients, config) + + on_exit(fn -> + Application.put_env(:lightning, :demo_oauth_clients, previous) + end) + end + + describe "setup_demo_oauth_clients/1" do + setup do + with_oauth_config() + end + + test "creates all 10 OAuth clients for a user" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + {:ok, clients} = + SetupUtils.setup_demo_oauth_clients(user_email: user.email) + + created = + clients + |> Enum.reject(fn {_k, v} -> v == :skipped end) + |> Map.new() + + assert map_size(created) == 10 + + assert Map.has_key?(created, :google_drive) + assert Map.has_key?(created, :google_sheets) + assert Map.has_key?(created, :gmail) + assert Map.has_key?(created, :salesforce) + assert Map.has_key?(created, :salesforce_sandbox) + assert Map.has_key?(created, :microsoft_sharepoint) + assert Map.has_key?(created, :microsoft_outlook) + assert Map.has_key?(created, :microsoft_calendar) + assert Map.has_key?(created, :microsoft_onedrive) + assert Map.has_key?(created, :microsoft_teams) + + # Verify they are global clients + assert Enum.all?(created, fn {_k, client} -> client.global end) + end + + test "is idempotent — skips already existing clients" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + {:ok, first_run} = + SetupUtils.setup_demo_oauth_clients(user_email: user.email) + + assert Enum.all?(first_run, fn {_k, v} -> v != :skipped end) + + {:ok, second_run} = + SetupUtils.setup_demo_oauth_clients(user_email: user.email) + + assert Enum.all?(second_run, fn {_k, v} -> v == :skipped end) + end + + test "returns error when user email not found" do + assert {:error, :user_not_found} = + SetupUtils.setup_demo_oauth_clients( + user_email: "nonexistent@example.com" + ) + end + + test "returns error when no users exist" do + assert {:error, :no_users_found} = SetupUtils.setup_demo_oauth_clients() + end + + test "falls back to superuser when no email provided" do + %{super_user: super_user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + {:ok, clients} = SetupUtils.setup_demo_oauth_clients() + + {_key, client} = Enum.find(clients, fn {_k, v} -> v != :skipped end) + assert client.user_id == super_user.id + end + + test "creates only specified clients with :only option" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + {:ok, clients} = + SetupUtils.setup_demo_oauth_clients( + user_email: user.email, + only: [:google_sheets, :salesforce] + ) + + assert map_size(clients) == 2 + assert Map.has_key?(clients, :google_sheets) + assert Map.has_key?(clients, :salesforce) + refute Map.has_key?(clients, :google_drive) + end + + test "skips unconfigured clients when no :only specified" do + with_oauth_config( + google_sheets: [ + client_id: "test-gs-id", + client_secret: "test-gs-secret" + ] + ) + + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + {:ok, clients} = + SetupUtils.setup_demo_oauth_clients(user_email: user.email) + + # google_sheets was created, everything else is :not_configured + assert map_size(clients) == 10 + refute clients.google_sheets == :not_configured + refute clients.google_sheets == :skipped + assert clients.google_drive == :not_configured + assert clients.gmail == :not_configured + end + + test "raises when :only requests unconfigured clients" do + with_oauth_config([]) + + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + assert_raise RuntimeError, + ~r/Missing OAuth client configuration for: google_sheets/, + fn -> + SetupUtils.setup_demo_oauth_clients( + user_email: user.email, + only: [:google_sheets] + ) + end + end + + test "raises listing only the unconfigured clients when :only is mixed" do + with_oauth_config( + gmail: [client_id: "test-id", client_secret: "test-secret"] + ) + + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + error = + assert_raise RuntimeError, fn -> + SetupUtils.setup_demo_oauth_clients( + user_email: user.email, + only: [:gmail, :google_sheets] + ) + end + + assert error.message =~ "google_sheets" + refute error.message =~ "gmail" + end + + test "treats partially configured client as not_configured" do + with_oauth_config( + google_sheets: [client_id: "only-id", client_secret: nil] + ) + + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + {:ok, clients} = + SetupUtils.setup_demo_oauth_clients(user_email: user.email) + + assert clients.google_sheets == :not_configured + end + end + + describe "list_demo_oauth_clients/0" do + test "returns all clients as configured when env vars are set" do + with_oauth_config() + + result = SetupUtils.list_demo_oauth_clients() + assert length(result) == 10 + assert Enum.all?(result, fn {_, status} -> status == :configured end) + end + + test "returns not_configured when env vars are missing" do + with_oauth_config([]) + + result = SetupUtils.list_demo_oauth_clients() + assert length(result) == 10 + assert Enum.all?(result, fn {_, status} -> status == :not_configured end) + end + + test "returns mixed status with partial config" do + with_oauth_config(gmail: [client_id: "id", client_secret: "secret"]) + + result = SetupUtils.list_demo_oauth_clients() + assert {:gmail, :configured} in result + assert {:google_drive, :not_configured} in result + end + end + + describe "create_demo_oauth_clients/2" do + setup do + with_oauth_config() + end + + test "is idempotent when called directly" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + first_result = SetupUtils.create_demo_oauth_clients(user) + assert Enum.all?(first_result, fn {_k, v} -> v != :skipped end) + + # Calling again should skip all + second_result = SetupUtils.create_demo_oauth_clients(user) + assert Enum.all?(second_result, fn {_k, v} -> v == :skipped end) + end + + test "filters with :only option" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + result = SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) + + assert map_size(result) == 1 + assert Map.has_key?(result, :gmail) + assert result.gmail.name == "Gmail" + end + end + describe "Setup demo site seed data" do setup do Lightning.SetupUtils.setup_demo(create_super: true) From 546167e4b5ac630ee8398cea8ca07bbbaa911ac3 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Sat, 14 Mar 2026 13:38:40 +0000 Subject: [PATCH 2/6] feat: add --credentials flag to create dummy OAuth credentials Users can now create placeholder OAuth credentials that can be reauthorized later through the UI. Supports optional --project flag to attach credentials to a specific project. --- lib/lightning/setup_utils.ex | 134 ++++++++++++++++++++++ lib/mix/tasks/setup_demo_oauth_clients.ex | 125 +++++++++++++++++--- test/lightning/setup_utils_test.exs | 108 +++++++++++++++++ 3 files changed, 348 insertions(+), 19 deletions(-) diff --git a/lib/lightning/setup_utils.ex b/lib/lightning/setup_utils.ex index cc9439f15b..83f2b81e84 100644 --- a/lib/lightning/setup_utils.ex +++ b/lib/lightning/setup_utils.ex @@ -196,6 +196,140 @@ defmodule Lightning.SetupUtils do create_demo_oauth_clients_impl(user.id, opts[:only]) end + @doc """ + Creates dummy OAuth credentials for existing demo OAuth clients. + + Finds the user and delegates to `create_demo_oauth_credentials/2`. + + ## Options + - `:user_email` - Email of the user who will own the credentials. + - `:only` - List of client keys to create credentials for. + - `:project_id` - If provided, attaches credentials to this project. + """ + def setup_demo_oauth_credentials(opts \\ []) do + case find_oauth_client_owner(opts[:user_email]) do + {:ok, user} -> + {:ok, create_demo_oauth_credentials(user, opts)} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Creates dummy OAuth credentials for existing demo OAuth clients. + + For each demo OAuth client found in the database, creates a credential + with placeholder token data that users can later reauthorize through the UI. + + ## Options + - `:only` - List of client keys to create credentials for. + - `:project_id` - If provided, attaches credentials to this project. + + ## Returns + A map of `%{atom => %Credential{} | :skipped | :no_oauth_client}`. + """ + def create_demo_oauth_credentials(%Accounts.User{} = user, opts \\ []) do + project_id = opts[:project_id] + only = opts[:only] + + keys = if only, do: only, else: all_keys() + + # Look up existing OAuth clients by their demo names + name_map = Map.new(keys, fn key -> {demo_client_name(key), key} end) + client_names = Map.keys(name_map) + + existing_clients = + from(c in Lightning.Credentials.OauthClient, + where: c.name in ^client_names + ) + |> Repo.all() + |> Map.new(fn c -> {c.name, c} end) + + # Check which credentials already exist for this user + existing_cred_names = + from(c in Lightning.Credentials.Credential, + where: c.user_id == ^user.id, + select: c.name + ) + |> Repo.all() + |> MapSet.new() + + Enum.reduce(keys, %{}, fn key, acc -> + client_name = demo_client_name(key) + cred_name = "#{client_name} - demo" + client = Map.get(existing_clients, client_name) + + cond do + is_nil(client) -> + Map.put(acc, key, :no_oauth_client) + + MapSet.member?(existing_cred_names, cred_name) -> + Map.put(acc, key, :skipped) + + true -> + cred_attrs = + build_dummy_credential_attrs( + user.id, + client, + cred_name, + project_id + ) + + case Credentials.create_credential(cred_attrs) do + {:ok, credential} -> + Map.put(acc, key, credential) + + {:error, reason} -> + raise "Failed to create credential for #{key}: #{inspect(reason)}" + end + end + end) + end + + defp build_dummy_credential_attrs(user_id, oauth_client, cred_name, project_id) do + attrs = %{ + "user_id" => user_id, + "name" => cred_name, + "schema" => "oauth", + "oauth_client_id" => oauth_client.id, + "credential_bodies" => [ + %{ + "name" => "main", + "body" => %{ + "access_token" => "placeholder_replace_by_reauthorizing", + "refresh_token" => "placeholder_replace_by_reauthorizing", + "token_type" => "Bearer", + "scope" => oauth_client.mandatory_scopes || "openid", + "expires_in" => 3600 + } + } + ] + } + + if project_id do + Map.put(attrs, "project_credentials", [ + %{"project_id" => project_id} + ]) + else + attrs + end + end + + @doc """ + Returns the display name for a demo OAuth client key. + """ + def demo_client_name(:google_drive), do: "Google Drive" + def demo_client_name(:google_sheets), do: "Google Sheets" + def demo_client_name(:gmail), do: "Gmail" + def demo_client_name(:salesforce), do: "Salesforce" + def demo_client_name(:salesforce_sandbox), do: "Salesforce Sandbox" + def demo_client_name(:microsoft_sharepoint), do: "Microsoft SharePoint" + def demo_client_name(:microsoft_outlook), do: "Microsoft Outlook" + def demo_client_name(:microsoft_calendar), do: "Microsoft Calendar" + def demo_client_name(:microsoft_onedrive), do: "Microsoft OneDrive" + def demo_client_name(:microsoft_teams), do: "Microsoft Teams" + @doc """ Returns a list of all available demo OAuth client keys and their configuration status. diff --git a/lib/mix/tasks/setup_demo_oauth_clients.ex b/lib/mix/tasks/setup_demo_oauth_clients.ex index aa48aae389..d778bed8d4 100644 --- a/lib/mix/tasks/setup_demo_oauth_clients.ex +++ b/lib/mix/tasks/setup_demo_oauth_clients.ex @@ -1,8 +1,9 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do - @shortdoc "Set up demo OAuth clients for Google, Salesforce, and Microsoft" + @shortdoc "Set up demo OAuth clients and optional dummy credentials" @moduledoc """ - Sets up demo OAuth clients for Google, Salesforce, and Microsoft services. + Sets up demo OAuth clients for Google, Salesforce, and Microsoft services, + with optional dummy credentials that users can reauthorize later. This task creates global OAuth clients that can be used across all projects. It will skip any OAuth clients that already exist (by name). @@ -23,6 +24,12 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do * `--list` - Show available clients and their configuration status, then exit without creating anything. + * `--credentials` - Also create dummy OAuth credentials for each + OAuth client. Users can reauthorize these later through the UI. + + * `--project` - Attach created credentials to this project (by ID). + Only used with `--credentials`. + ## Available client keys google_drive, google_sheets, gmail, @@ -51,6 +58,12 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do # Create only Google Sheets and Salesforce mix lightning.setup_demo_oauth_clients --only google_sheets,salesforce + # Create OAuth clients + dummy credentials + mix lightning.setup_demo_oauth_clients --credentials + + # Create OAuth clients + dummy credentials attached to a project + mix lightning.setup_demo_oauth_clients --credentials --project + # Specify a user by email mix lightning.setup_demo_oauth_clients --email admin@example.com --only gmail """ @@ -65,8 +78,14 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do def run(args) do {opts, _, invalid} = OptionParser.parse(args, - strict: [email: :string, only: :string, list: :boolean], - aliases: [e: :email, o: :only, l: :list] + strict: [ + email: :string, + only: :string, + list: :boolean, + credentials: :boolean, + project: :string + ], + aliases: [e: :email, o: :only, l: :list, c: :credentials, p: :project] ) if invalid != [] do @@ -79,6 +98,14 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do """) end + if opts[:project] && !opts[:credentials] do + Mix.raise(""" + --project requires --credentials. + + Use: mix lightning.setup_demo_oauth_clients --credentials --project + """) + end + Mix.Task.run("app.start") if opts[:list] do @@ -137,8 +164,12 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do ) case SetupUtils.setup_demo_oauth_clients(setup_opts) do - {:ok, results} -> - print_results(results) + {:ok, client_results} -> + print_client_results(client_results) + + if opts[:credentials] do + create_credentials(opts, only) + end {:error, :no_users_found} -> Mix.raise(""" @@ -157,6 +188,25 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do end end + defp create_credentials(opts, only) do + cred_opts = + Enum.reject( + [user_email: opts[:email], only: only, project_id: opts[:project]], + fn {_k, v} -> is_nil(v) end + ) + + case SetupUtils.setup_demo_oauth_credentials(cred_opts) do + {:ok, cred_results} -> + print_credential_results(cred_results) + + {:error, :no_users_found} -> + Mix.raise("No users found.") + + {:error, :user_not_found} -> + Mix.raise("User not found with email: #{opts[:email]}") + end + end + defp parse_only(nil), do: nil defp parse_only(value) do @@ -180,7 +230,7 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do Enum.map(strings, &String.to_existing_atom/1) end - defp print_results(results) do + defp print_client_results(results) do created = Enum.filter(results, fn {_, v} -> not is_atom(v) end) @@ -191,7 +241,7 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do Enum.filter(results, fn {_, v} -> v == :not_configured end) if created != [] do - Mix.shell().info("\nCreated:") + Mix.shell().info("\nOAuth clients created:") Enum.each(created, fn {key, client} -> Mix.shell().info(" ✓ #{client.name} (#{key})") @@ -199,10 +249,10 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do end if skipped != [] do - Mix.shell().info("\nAlready exist:") + Mix.shell().info("\nOAuth clients already exist:") Enum.each(skipped, fn {key, _} -> - Mix.shell().info(" - #{format_key(key)}") + Mix.shell().info(" - #{SetupUtils.demo_client_name(key)}") end) end @@ -211,7 +261,10 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do Enum.each(not_configured, fn {key, _} -> {id_var, secret_var} = SetupUtils.env_vars_for(key) - Mix.shell().info(" ✗ #{format_key(key)} (set #{id_var}, #{secret_var})") + + Mix.shell().info( + " ✗ #{SetupUtils.demo_client_name(key)} (set #{id_var}, #{secret_var})" + ) end) end @@ -223,17 +276,51 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do """) else Mix.shell().info( - "\nDone! Created #{length(created)}, skipped #{length(skipped)}, not configured #{length(not_configured)}." + "\nClients: #{length(created)} created, #{length(skipped)} skipped, #{length(not_configured)} not configured." ) end end - defp format_key(key) do - key - |> Atom.to_string() - |> String.replace("_", " ") - |> String.split() - |> Enum.map(&String.capitalize/1) - |> Enum.join(" ") + defp print_credential_results(results) do + created = + Enum.filter(results, fn {_, v} -> not is_atom(v) end) + + skipped = + Enum.filter(results, fn {_, v} -> v == :skipped end) + + no_client = + Enum.filter(results, fn {_, v} -> v == :no_oauth_client end) + + if created != [] do + Mix.shell().info("\nCredentials created:") + + Enum.each(created, fn {key, cred} -> + Mix.shell().info(" ✓ #{cred.name} (#{key})") + end) + + Mix.shell().info( + "\n These credentials have placeholder tokens. Users should reauthorize\n through the UI to get real access tokens." + ) + end + + if skipped != [] do + Mix.shell().info("\nCredentials already exist:") + + Enum.each(skipped, fn {key, _} -> + Mix.shell().info(" - #{SetupUtils.demo_client_name(key)} - demo") + end) + end + + if no_client != [] do + Mix.shell().info("\nNo OAuth client found for:") + + Enum.each(no_client, fn {key, _} -> + Mix.shell().info(" ✗ #{SetupUtils.demo_client_name(key)}") + end) + end + + Mix.shell().info( + "\nCredentials: #{length(created)} created, #{length(skipped)} skipped, #{length(no_client)} missing client." + ) end end diff --git a/test/lightning/setup_utils_test.exs b/test/lightning/setup_utils_test.exs index 07f8ce1b4f..d4c3760de9 100644 --- a/test/lightning/setup_utils_test.exs +++ b/test/lightning/setup_utils_test.exs @@ -262,6 +262,114 @@ defmodule Lightning.SetupUtilsTest do end end + describe "create_demo_oauth_credentials/2" do + setup do + with_oauth_config() + end + + test "creates dummy credentials for each existing OAuth client" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + SetupUtils.create_demo_oauth_clients(user, only: [:gmail, :salesforce]) + + result = + SetupUtils.create_demo_oauth_credentials(user, + only: [:gmail, :salesforce] + ) + + assert map_size(result) == 2 + assert result.gmail.name == "Gmail - demo" + assert result.salesforce.name == "Salesforce - demo" + + # Verify credentials are linked to their OAuth clients + gmail_cred = Lightning.Repo.preload(result.gmail, :oauth_client) + assert gmail_cred.oauth_client.name == "Gmail" + end + + test "is idempotent — skips existing credentials" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) + + first = SetupUtils.create_demo_oauth_credentials(user, only: [:gmail]) + assert first.gmail.name == "Gmail - demo" + + second = SetupUtils.create_demo_oauth_credentials(user, only: [:gmail]) + assert second.gmail == :skipped + end + + test "returns :no_oauth_client when OAuth client doesn't exist" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + # Don't create any OAuth clients first + result = SetupUtils.create_demo_oauth_credentials(user, only: [:gmail]) + assert result.gmail == :no_oauth_client + end + + test "attaches credentials to a project when :project_id given" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + {:ok, project} = + Lightning.Projects.create_project(%{ + name: "test-project", + project_users: [%{user_id: user.id, role: :owner}] + }) + + SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) + + result = + SetupUtils.create_demo_oauth_credentials(user, + only: [:gmail], + project_id: project.id + ) + + cred = + Lightning.Repo.preload(result.gmail, :project_credentials) + + assert length(cred.project_credentials) == 1 + assert hd(cred.project_credentials).project_id == project.id + end + + test "creates credentials without project when no :project_id" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) + + result = SetupUtils.create_demo_oauth_credentials(user, only: [:gmail]) + + cred = + Lightning.Repo.preload(result.gmail, :project_credentials) + + assert cred.project_credentials == [] + end + end + + describe "setup_demo_oauth_credentials/1" do + setup do + with_oauth_config() + end + + test "finds user and creates credentials" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) + + {:ok, result} = + SetupUtils.setup_demo_oauth_credentials( + user_email: user.email, + only: [:gmail] + ) + + assert result.gmail.name == "Gmail - demo" + end + end + describe "Setup demo site seed data" do setup do Lightning.SetupUtils.setup_demo(create_super: true) From 0e4eaf52adbeba043f65898182fd78e5249e24bc Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Sat, 14 Mar 2026 13:45:12 +0000 Subject: [PATCH 3/6] feat: add mix task for creating individual OAuth credentials Add `mix lightning.create_oauth_credential` for creating a single dummy OAuth credential with full control over name, user, and project. Replace bulk credential creation with a focused single-credential function (create_dummy_oauth_credential/3) that's composable and flexible. --- lib/lightning/setup_utils.ex | 120 +++--------- lib/mix/tasks/create_oauth_credential.ex | 215 ++++++++++++++++++++++ lib/mix/tasks/setup_demo_oauth_clients.ex | 113 +----------- test/lightning/setup_utils_test.exs | 92 ++++----- 4 files changed, 290 insertions(+), 250 deletions(-) create mode 100644 lib/mix/tasks/create_oauth_credential.ex diff --git a/lib/lightning/setup_utils.ex b/lib/lightning/setup_utils.ex index 83f2b81e84..65b370e963 100644 --- a/lib/lightning/setup_utils.ex +++ b/lib/lightning/setup_utils.ex @@ -197,99 +197,32 @@ defmodule Lightning.SetupUtils do end @doc """ - Creates dummy OAuth credentials for existing demo OAuth clients. + Creates a dummy OAuth credential for a given OAuth client. - Finds the user and delegates to `create_demo_oauth_credentials/2`. + The credential has placeholder token data that users can reauthorize + through the UI to get real access tokens. - ## Options - - `:user_email` - Email of the user who will own the credentials. - - `:only` - List of client keys to create credentials for. - - `:project_id` - If provided, attaches credentials to this project. - """ - def setup_demo_oauth_credentials(opts \\ []) do - case find_oauth_client_owner(opts[:user_email]) do - {:ok, user} -> - {:ok, create_demo_oauth_credentials(user, opts)} - - {:error, reason} -> - {:error, reason} - end - end - - @doc """ - Creates dummy OAuth credentials for existing demo OAuth clients. - - For each demo OAuth client found in the database, creates a credential - with placeholder token data that users can later reauthorize through the UI. - - ## Options - - `:only` - List of client keys to create credentials for. - - `:project_id` - If provided, attaches credentials to this project. + ## Parameters + - `oauth_client` - The `%OauthClient{}` to create a credential for. + - `user` - The `%User{}` who will own the credential. + - `opts` - Keyword list with options: + - `:name` - Custom credential name. Defaults to "\#{client_name} - demo". + - `:project_id` - If provided, attaches the credential to this project. ## Returns - A map of `%{atom => %Credential{} | :skipped | :no_oauth_client}`. + - `{:ok, %Credential{}}` on success. + - `{:error, reason}` on failure. """ - def create_demo_oauth_credentials(%Accounts.User{} = user, opts \\ []) do + def create_dummy_oauth_credential( + %Lightning.Credentials.OauthClient{} = oauth_client, + %Accounts.User{} = user, + opts \\ [] + ) do + cred_name = opts[:name] || "#{oauth_client.name} - demo" project_id = opts[:project_id] - only = opts[:only] - - keys = if only, do: only, else: all_keys() - # Look up existing OAuth clients by their demo names - name_map = Map.new(keys, fn key -> {demo_client_name(key), key} end) - client_names = Map.keys(name_map) - - existing_clients = - from(c in Lightning.Credentials.OauthClient, - where: c.name in ^client_names - ) - |> Repo.all() - |> Map.new(fn c -> {c.name, c} end) - - # Check which credentials already exist for this user - existing_cred_names = - from(c in Lightning.Credentials.Credential, - where: c.user_id == ^user.id, - select: c.name - ) - |> Repo.all() - |> MapSet.new() - - Enum.reduce(keys, %{}, fn key, acc -> - client_name = demo_client_name(key) - cred_name = "#{client_name} - demo" - client = Map.get(existing_clients, client_name) - - cond do - is_nil(client) -> - Map.put(acc, key, :no_oauth_client) - - MapSet.member?(existing_cred_names, cred_name) -> - Map.put(acc, key, :skipped) - - true -> - cred_attrs = - build_dummy_credential_attrs( - user.id, - client, - cred_name, - project_id - ) - - case Credentials.create_credential(cred_attrs) do - {:ok, credential} -> - Map.put(acc, key, credential) - - {:error, reason} -> - raise "Failed to create credential for #{key}: #{inspect(reason)}" - end - end - end) - end - - defp build_dummy_credential_attrs(user_id, oauth_client, cred_name, project_id) do attrs = %{ - "user_id" => user_id, + "user_id" => user.id, "name" => cred_name, "schema" => "oauth", "oauth_client_id" => oauth_client.id, @@ -307,13 +240,16 @@ defmodule Lightning.SetupUtils do ] } - if project_id do - Map.put(attrs, "project_credentials", [ - %{"project_id" => project_id} - ]) - else - attrs - end + attrs = + if project_id do + Map.put(attrs, "project_credentials", [ + %{"project_id" => project_id} + ]) + else + attrs + end + + Credentials.create_credential(attrs) end @doc """ diff --git a/lib/mix/tasks/create_oauth_credential.ex b/lib/mix/tasks/create_oauth_credential.ex new file mode 100644 index 0000000000..e6e6523b60 --- /dev/null +++ b/lib/mix/tasks/create_oauth_credential.ex @@ -0,0 +1,215 @@ +defmodule Mix.Tasks.Lightning.CreateOauthCredential do + @shortdoc "Create a dummy OAuth credential for an existing OAuth client" + + @moduledoc """ + Creates a dummy OAuth credential with placeholder tokens that users + can reauthorize later through the UI. + + ## Usage + + mix lightning.create_oauth_credential --client [OPTIONS] + + ## Options + + * `--client` (required) - OAuth client to create the credential for. + Accepts a client name (e.g., "Google Sheets") or UUID. + + * `--name` - Custom credential name. + Defaults to " - demo". + + * `--email` - Email of the user who will own the credential. + If not provided, uses the first superuser found, or falls back to + the first user in the system. + + * `--project` - Project ID to attach the credential to. + + ## Examples + + # Create a credential for Google Sheets + mix lightning.create_oauth_credential --client "Google Sheets" + + # Custom name + mix lightning.create_oauth_credential --client "Google Sheets" --name "My GSheets Cred" + + # Attach to a project + mix lightning.create_oauth_credential --client salesforce --project + + # Specify owner + mix lightning.create_oauth_credential --client gmail --email dev@example.com + + ## Notes + + The credential is created with placeholder tokens. Users must reauthorize + through the Lightning UI to get real access tokens. + """ + + use Mix.Task + + alias Lightning.Credentials.OauthClient + alias Lightning.Repo + alias Lightning.SetupUtils + + import Ecto.Query + + @impl Mix.Task + def run(args) do + {opts, _, invalid} = + OptionParser.parse(args, + strict: [ + client: :string, + name: :string, + email: :string, + project: :string + ], + aliases: [c: :client, n: :name, e: :email, p: :project] + ) + + if invalid != [] do + invalid_opts = Enum.map_join(invalid, ", ", fn {opt, _} -> opt end) + + Mix.raise(""" + Unknown option(s): #{invalid_opts} + + Run `mix help lightning.create_oauth_credential` for more information. + """) + end + + unless opts[:client] do + Mix.raise(""" + --client is required. + + Usage: mix lightning.create_oauth_credential --client + + Run `mix help lightning.create_oauth_credential` for more information. + """) + end + + Mix.Task.run("app.start") + + with {:ok, oauth_client} <- find_oauth_client(opts[:client]), + {:ok, user} <- find_user(opts[:email]) do + cred_opts = + Enum.reject( + [name: opts[:name], project_id: opts[:project]], + fn {_k, v} -> is_nil(v) end + ) + + case SetupUtils.create_dummy_oauth_credential( + oauth_client, + user, + cred_opts + ) do + {:ok, credential} -> + credential = + Repo.preload(credential, [:oauth_client, :project_credentials]) + + Mix.shell().info("\n✓ Created credential: #{credential.name}") + Mix.shell().info(" OAuth client: #{oauth_client.name}") + Mix.shell().info(" Owner: #{user.email}") + + case credential.project_credentials do + [pc | _] -> + Mix.shell().info(" Project: #{pc.project_id}") + + [] -> + Mix.shell().info(" Project: (none)") + end + + Mix.shell().info( + "\n This credential has placeholder tokens. Reauthorize through\n the Lightning UI to get real access tokens.\n" + ) + + {:error, %Ecto.Changeset{} = changeset} -> + errors = + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> + opts + |> Keyword.get(String.to_existing_atom(key), key) + |> to_string() + end) + end) + + Mix.raise("Failed to create credential: #{inspect(errors)}") + + {:error, %Lightning.Credentials.OauthValidation.Error{} = error} -> + Mix.raise("Token validation failed: #{inspect(error)}") + + {:error, reason} -> + Mix.raise("Failed to create credential: #{inspect(reason)}") + end + end + end + + defp find_oauth_client(client_ref) do + # Try UUID first, then name + query = + if match?({:ok, _}, Ecto.UUID.cast(client_ref)) do + from(c in OauthClient, where: c.id == ^client_ref) + else + from(c in OauthClient, where: c.name == ^client_ref) + end + + case Repo.one(query) do + nil -> + available = + from(c in OauthClient, select: c.name, order_by: [asc: c.name]) + |> Repo.all() + + if available == [] do + Mix.raise(""" + No OAuth client found matching "#{client_ref}". + + No OAuth clients exist yet. Create some first: + mix lightning.setup_demo_oauth_clients + """) + else + Mix.raise(""" + No OAuth client found matching "#{client_ref}". + + Available OAuth clients: + #{Enum.map_join(available, "\n", &" - #{&1}")} + """) + end + + client -> + {:ok, client} + end + end + + defp find_user(nil) do + alias Lightning.Accounts.User + + case Repo.one(from(u in User, where: u.role == :superuser, limit: 1)) do + nil -> + case Repo.one(from(u in User, limit: 1)) do + nil -> + Mix.raise(""" + No users found in the database. + + Please create at least one user before running this task: + mix run -e 'Lightning.SetupUtils.setup_demo()' + """) + + user -> + {:ok, user} + end + + user -> + {:ok, user} + end + end + + defp find_user(email) do + case Lightning.Accounts.get_user_by_email(email) do + nil -> + Mix.raise(""" + User not found with email: #{email} + + Please check the email address and try again. + """) + + user -> + {:ok, user} + end + end +end diff --git a/lib/mix/tasks/setup_demo_oauth_clients.ex b/lib/mix/tasks/setup_demo_oauth_clients.ex index d778bed8d4..d773a324e4 100644 --- a/lib/mix/tasks/setup_demo_oauth_clients.ex +++ b/lib/mix/tasks/setup_demo_oauth_clients.ex @@ -1,9 +1,8 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do - @shortdoc "Set up demo OAuth clients and optional dummy credentials" + @shortdoc "Set up demo OAuth clients for Google, Salesforce, and Microsoft" @moduledoc """ - Sets up demo OAuth clients for Google, Salesforce, and Microsoft services, - with optional dummy credentials that users can reauthorize later. + Sets up demo OAuth clients for Google, Salesforce, and Microsoft services. This task creates global OAuth clients that can be used across all projects. It will skip any OAuth clients that already exist (by name). @@ -24,12 +23,6 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do * `--list` - Show available clients and their configuration status, then exit without creating anything. - * `--credentials` - Also create dummy OAuth credentials for each - OAuth client. Users can reauthorize these later through the UI. - - * `--project` - Attach created credentials to this project (by ID). - Only used with `--credentials`. - ## Available client keys google_drive, google_sheets, gmail, @@ -58,12 +51,6 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do # Create only Google Sheets and Salesforce mix lightning.setup_demo_oauth_clients --only google_sheets,salesforce - # Create OAuth clients + dummy credentials - mix lightning.setup_demo_oauth_clients --credentials - - # Create OAuth clients + dummy credentials attached to a project - mix lightning.setup_demo_oauth_clients --credentials --project - # Specify a user by email mix lightning.setup_demo_oauth_clients --email admin@example.com --only gmail """ @@ -78,14 +65,8 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do def run(args) do {opts, _, invalid} = OptionParser.parse(args, - strict: [ - email: :string, - only: :string, - list: :boolean, - credentials: :boolean, - project: :string - ], - aliases: [e: :email, o: :only, l: :list, c: :credentials, p: :project] + strict: [email: :string, only: :string, list: :boolean], + aliases: [e: :email, o: :only, l: :list] ) if invalid != [] do @@ -98,14 +79,6 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do """) end - if opts[:project] && !opts[:credentials] do - Mix.raise(""" - --project requires --credentials. - - Use: mix lightning.setup_demo_oauth_clients --credentials --project - """) - end - Mix.Task.run("app.start") if opts[:list] do @@ -164,12 +137,8 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do ) case SetupUtils.setup_demo_oauth_clients(setup_opts) do - {:ok, client_results} -> - print_client_results(client_results) - - if opts[:credentials] do - create_credentials(opts, only) - end + {:ok, results} -> + print_results(results) {:error, :no_users_found} -> Mix.raise(""" @@ -188,25 +157,6 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do end end - defp create_credentials(opts, only) do - cred_opts = - Enum.reject( - [user_email: opts[:email], only: only, project_id: opts[:project]], - fn {_k, v} -> is_nil(v) end - ) - - case SetupUtils.setup_demo_oauth_credentials(cred_opts) do - {:ok, cred_results} -> - print_credential_results(cred_results) - - {:error, :no_users_found} -> - Mix.raise("No users found.") - - {:error, :user_not_found} -> - Mix.raise("User not found with email: #{opts[:email]}") - end - end - defp parse_only(nil), do: nil defp parse_only(value) do @@ -230,7 +180,7 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do Enum.map(strings, &String.to_existing_atom/1) end - defp print_client_results(results) do + defp print_results(results) do created = Enum.filter(results, fn {_, v} -> not is_atom(v) end) @@ -241,7 +191,7 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do Enum.filter(results, fn {_, v} -> v == :not_configured end) if created != [] do - Mix.shell().info("\nOAuth clients created:") + Mix.shell().info("\nCreated:") Enum.each(created, fn {key, client} -> Mix.shell().info(" ✓ #{client.name} (#{key})") @@ -249,7 +199,7 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do end if skipped != [] do - Mix.shell().info("\nOAuth clients already exist:") + Mix.shell().info("\nAlready exist:") Enum.each(skipped, fn {key, _} -> Mix.shell().info(" - #{SetupUtils.demo_client_name(key)}") @@ -276,51 +226,8 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do """) else Mix.shell().info( - "\nClients: #{length(created)} created, #{length(skipped)} skipped, #{length(not_configured)} not configured." - ) - end - end - - defp print_credential_results(results) do - created = - Enum.filter(results, fn {_, v} -> not is_atom(v) end) - - skipped = - Enum.filter(results, fn {_, v} -> v == :skipped end) - - no_client = - Enum.filter(results, fn {_, v} -> v == :no_oauth_client end) - - if created != [] do - Mix.shell().info("\nCredentials created:") - - Enum.each(created, fn {key, cred} -> - Mix.shell().info(" ✓ #{cred.name} (#{key})") - end) - - Mix.shell().info( - "\n These credentials have placeholder tokens. Users should reauthorize\n through the UI to get real access tokens." + "\nDone! Created #{length(created)}, skipped #{length(skipped)}, not configured #{length(not_configured)}." ) end - - if skipped != [] do - Mix.shell().info("\nCredentials already exist:") - - Enum.each(skipped, fn {key, _} -> - Mix.shell().info(" - #{SetupUtils.demo_client_name(key)} - demo") - end) - end - - if no_client != [] do - Mix.shell().info("\nNo OAuth client found for:") - - Enum.each(no_client, fn {key, _} -> - Mix.shell().info(" ✗ #{SetupUtils.demo_client_name(key)}") - end) - end - - Mix.shell().info( - "\nCredentials: #{length(created)} created, #{length(skipped)} skipped, #{length(no_client)} missing client." - ) end end diff --git a/test/lightning/setup_utils_test.exs b/test/lightning/setup_utils_test.exs index d4c3760de9..25a37d06f0 100644 --- a/test/lightning/setup_utils_test.exs +++ b/test/lightning/setup_utils_test.exs @@ -262,54 +262,42 @@ defmodule Lightning.SetupUtilsTest do end end - describe "create_demo_oauth_credentials/2" do + describe "create_dummy_oauth_credential/3" do setup do with_oauth_config() end - test "creates dummy credentials for each existing OAuth client" do + test "creates a credential with default name" do %{super_user: user} = SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() - SetupUtils.create_demo_oauth_clients(user, only: [:gmail, :salesforce]) + clients = SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) - result = - SetupUtils.create_demo_oauth_credentials(user, - only: [:gmail, :salesforce] - ) + {:ok, cred} = + SetupUtils.create_dummy_oauth_credential(clients.gmail, user) - assert map_size(result) == 2 - assert result.gmail.name == "Gmail - demo" - assert result.salesforce.name == "Salesforce - demo" + assert cred.name == "Gmail - demo" + assert cred.schema == "oauth" - # Verify credentials are linked to their OAuth clients - gmail_cred = Lightning.Repo.preload(result.gmail, :oauth_client) - assert gmail_cred.oauth_client.name == "Gmail" + cred = Lightning.Repo.preload(cred, :oauth_client) + assert cred.oauth_client.id == clients.gmail.id end - test "is idempotent — skips existing credentials" do + test "creates a credential with custom name" do %{super_user: user} = SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() - SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) - - first = SetupUtils.create_demo_oauth_credentials(user, only: [:gmail]) - assert first.gmail.name == "Gmail - demo" + clients = SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) - second = SetupUtils.create_demo_oauth_credentials(user, only: [:gmail]) - assert second.gmail == :skipped - end - - test "returns :no_oauth_client when OAuth client doesn't exist" do - %{super_user: user} = - SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + {:ok, cred} = + SetupUtils.create_dummy_oauth_credential(clients.gmail, user, + name: "My Gmail Testing" + ) - # Don't create any OAuth clients first - result = SetupUtils.create_demo_oauth_credentials(user, only: [:gmail]) - assert result.gmail == :no_oauth_client + assert cred.name == "My Gmail Testing" end - test "attaches credentials to a project when :project_id given" do + test "attaches credential to a project" do %{super_user: user} = SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() @@ -319,54 +307,48 @@ defmodule Lightning.SetupUtilsTest do project_users: [%{user_id: user.id, role: :owner}] }) - SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) + clients = SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) - result = - SetupUtils.create_demo_oauth_credentials(user, - only: [:gmail], + {:ok, cred} = + SetupUtils.create_dummy_oauth_credential(clients.gmail, user, project_id: project.id ) - cred = - Lightning.Repo.preload(result.gmail, :project_credentials) - + cred = Lightning.Repo.preload(cred, :project_credentials) assert length(cred.project_credentials) == 1 assert hd(cred.project_credentials).project_id == project.id end - test "creates credentials without project when no :project_id" do + test "creates credential without project by default" do %{super_user: user} = SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() - SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) - - result = SetupUtils.create_demo_oauth_credentials(user, only: [:gmail]) + clients = SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) - cred = - Lightning.Repo.preload(result.gmail, :project_credentials) + {:ok, cred} = + SetupUtils.create_dummy_oauth_credential(clients.gmail, user) + cred = Lightning.Repo.preload(cred, :project_credentials) assert cred.project_credentials == [] end - end - - describe "setup_demo_oauth_credentials/1" do - setup do - with_oauth_config() - end - test "finds user and creates credentials" do + test "returns error for duplicate credential name" do %{super_user: user} = SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() - SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) + clients = SetupUtils.create_demo_oauth_clients(user, only: [:gmail]) - {:ok, result} = - SetupUtils.setup_demo_oauth_credentials( - user_email: user.email, - only: [:gmail] + {:ok, _} = + SetupUtils.create_dummy_oauth_credential(clients.gmail, user, + name: "Same Name" + ) + + {:error, changeset} = + SetupUtils.create_dummy_oauth_credential(clients.gmail, user, + name: "Same Name" ) - assert result.gmail.name == "Gmail - demo" + assert %Ecto.Changeset{valid?: false} = changeset end end From efdc385d9afbbc1386639b5908ad169a8ac895b8 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Sat, 14 Mar 2026 13:57:13 +0000 Subject: [PATCH 4/6] feat: add --list flag to create_oauth_credential task Users can discover available OAuth clients before creating credentials. Shows client names, IDs, and whether they're global. --- lib/mix/tasks/create_oauth_credential.ex | 60 ++++++++++++++++++++---- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/lib/mix/tasks/create_oauth_credential.ex b/lib/mix/tasks/create_oauth_credential.ex index e6e6523b60..c9b6f552cd 100644 --- a/lib/mix/tasks/create_oauth_credential.ex +++ b/lib/mix/tasks/create_oauth_credential.ex @@ -11,6 +11,8 @@ defmodule Mix.Tasks.Lightning.CreateOauthCredential do ## Options + * `--list` - Show available OAuth clients and exit. + * `--client` (required) - OAuth client to create the credential for. Accepts a client name (e.g., "Google Sheets") or UUID. @@ -25,6 +27,9 @@ defmodule Mix.Tasks.Lightning.CreateOauthCredential do ## Examples + # List available OAuth clients + mix lightning.create_oauth_credential --list + # Create a credential for Google Sheets mix lightning.create_oauth_credential --client "Google Sheets" @@ -59,9 +64,10 @@ defmodule Mix.Tasks.Lightning.CreateOauthCredential do client: :string, name: :string, email: :string, - project: :string + project: :string, + list: :boolean ], - aliases: [c: :client, n: :name, e: :email, p: :project] + aliases: [c: :client, n: :name, e: :email, p: :project, l: :list] ) if invalid != [] do @@ -74,18 +80,54 @@ defmodule Mix.Tasks.Lightning.CreateOauthCredential do """) end - unless opts[:client] do - Mix.raise(""" - --client is required. + Mix.Task.run("app.start") - Usage: mix lightning.create_oauth_credential --client + if opts[:list] do + list_oauth_clients() + else + unless opts[:client] do + Mix.raise(""" + --client is required. - Run `mix help lightning.create_oauth_credential` for more information. - """) + Usage: mix lightning.create_oauth_credential --client + + Use --list to see available OAuth clients. + """) + end + + create_credential(opts) end + end - Mix.Task.run("app.start") + defp list_oauth_clients do + clients = + from(c in OauthClient, order_by: [asc: fragment("lower(?)", c.name)]) + |> Repo.all() + + if clients == [] do + Mix.shell().info(""" + + No OAuth clients found. + + Create some first: + mix lightning.setup_demo_oauth_clients + """) + else + Mix.shell().info("\nAvailable OAuth clients:\n") + + Enum.each(clients, fn client -> + global = if client.global, do: " (global)", else: "" + Mix.shell().info(" - #{client.name}#{global}") + Mix.shell().info(" ID: #{client.id}") + end) + + Mix.shell().info( + "\nUse --client \"\" to create a credential for one of these.\n" + ) + end + end + defp create_credential(opts) do with {:ok, oauth_client} <- find_oauth_client(opts[:client]), {:ok, user} <- find_user(opts[:email]) do cred_opts = From 8b4ef244adc760ed457bf209c0c73de4f950d37a Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Sat, 14 Mar 2026 14:01:10 +0000 Subject: [PATCH 5/6] refactor: rename --email flag to --user for clarity --- lib/mix/tasks/create_oauth_credential.ex | 10 +++++----- lib/mix/tasks/setup_demo_oauth_clients.ex | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/mix/tasks/create_oauth_credential.ex b/lib/mix/tasks/create_oauth_credential.ex index c9b6f552cd..79c6bd2113 100644 --- a/lib/mix/tasks/create_oauth_credential.ex +++ b/lib/mix/tasks/create_oauth_credential.ex @@ -19,7 +19,7 @@ defmodule Mix.Tasks.Lightning.CreateOauthCredential do * `--name` - Custom credential name. Defaults to " - demo". - * `--email` - Email of the user who will own the credential. + * `--user` - Email of the user who will own the credential. If not provided, uses the first superuser found, or falls back to the first user in the system. @@ -40,7 +40,7 @@ defmodule Mix.Tasks.Lightning.CreateOauthCredential do mix lightning.create_oauth_credential --client salesforce --project # Specify owner - mix lightning.create_oauth_credential --client gmail --email dev@example.com + mix lightning.create_oauth_credential --client gmail --user dev@example.com ## Notes @@ -63,11 +63,11 @@ defmodule Mix.Tasks.Lightning.CreateOauthCredential do strict: [ client: :string, name: :string, - email: :string, + user: :string, project: :string, list: :boolean ], - aliases: [c: :client, n: :name, e: :email, p: :project, l: :list] + aliases: [c: :client, n: :name, u: :user, p: :project, l: :list] ) if invalid != [] do @@ -129,7 +129,7 @@ defmodule Mix.Tasks.Lightning.CreateOauthCredential do defp create_credential(opts) do with {:ok, oauth_client} <- find_oauth_client(opts[:client]), - {:ok, user} <- find_user(opts[:email]) do + {:ok, user} <- find_user(opts[:user]) do cred_opts = Enum.reject( [name: opts[:name], project_id: opts[:project]], diff --git a/lib/mix/tasks/setup_demo_oauth_clients.ex b/lib/mix/tasks/setup_demo_oauth_clients.ex index d773a324e4..69eb5713d8 100644 --- a/lib/mix/tasks/setup_demo_oauth_clients.ex +++ b/lib/mix/tasks/setup_demo_oauth_clients.ex @@ -13,7 +13,7 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do ## Options - * `--email` - Email of the user who will own the OAuth clients. + * `--user` - Email of the user who will own the OAuth clients. If not provided, uses the first superuser found, or falls back to the first user in the system. @@ -52,7 +52,7 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do mix lightning.setup_demo_oauth_clients --only google_sheets,salesforce # Specify a user by email - mix lightning.setup_demo_oauth_clients --email admin@example.com --only gmail + mix lightning.setup_demo_oauth_clients --user admin@example.com --only gmail """ use Mix.Task @@ -65,8 +65,8 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do def run(args) do {opts, _, invalid} = OptionParser.parse(args, - strict: [email: :string, only: :string, list: :boolean], - aliases: [e: :email, o: :only, l: :list] + strict: [user: :string, only: :string, list: :boolean], + aliases: [u: :user, o: :only, l: :list] ) if invalid != [] do @@ -132,7 +132,7 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do setup_opts = Enum.reject( - [user_email: opts[:email], only: only], + [user_email: opts[:user], only: only], fn {_k, v} -> is_nil(v) end ) @@ -150,7 +150,7 @@ defmodule Mix.Tasks.Lightning.SetupDemoOauthClients do {:error, :user_not_found} -> Mix.raise(""" - User not found with email: #{opts[:email]} + User not found with email: #{opts[:user]} Please check the email address and try again. """) From 13950ed768f04253834b978e3dd3972a31a21796 Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Sat, 14 Mar 2026 14:43:52 +0000 Subject: [PATCH 6/6] test: add comprehensive tests for mix tasks and utility functions Add tests for both mix tasks (setup_demo_oauth_clients, create_oauth_credential) covering happy paths, error cases, and flag handling. Add direct tests for all_keys/0, env_vars_for/1, demo_client_name/1, and credential token body verification. 52 tests total, 0 failures. --- test/lightning/setup_utils_test.exs | 83 +++++++ .../tasks/create_oauth_credential_test.exs | 202 ++++++++++++++++++ .../tasks/setup_demo_oauth_clients_test.exs | 159 ++++++++++++++ 3 files changed, 444 insertions(+) create mode 100644 test/mix/tasks/create_oauth_credential_test.exs create mode 100644 test/mix/tasks/setup_demo_oauth_clients_test.exs diff --git a/test/lightning/setup_utils_test.exs b/test/lightning/setup_utils_test.exs index 25a37d06f0..c8d7c4022c 100644 --- a/test/lightning/setup_utils_test.exs +++ b/test/lightning/setup_utils_test.exs @@ -207,6 +207,61 @@ defmodule Lightning.SetupUtilsTest do end end + describe "all_keys/0" do + test "returns all 10 demo OAuth client keys as atoms" do + keys = SetupUtils.all_keys() + + assert length(keys) == 10 + + assert :google_drive in keys + assert :google_sheets in keys + assert :gmail in keys + assert :salesforce in keys + assert :salesforce_sandbox in keys + assert :microsoft_sharepoint in keys + assert :microsoft_outlook in keys + assert :microsoft_calendar in keys + assert :microsoft_onedrive in keys + assert :microsoft_teams in keys + end + end + + describe "env_vars_for/1" do + test "returns uppercased CLIENT_ID and CLIENT_SECRET var names" do + assert {"GOOGLE_SHEETS_CLIENT_ID", "GOOGLE_SHEETS_CLIENT_SECRET"} = + SetupUtils.env_vars_for(:google_sheets) + + assert {"SALESFORCE_SANDBOX_CLIENT_ID", "SALESFORCE_SANDBOX_CLIENT_SECRET"} = + SetupUtils.env_vars_for(:salesforce_sandbox) + + assert {"MICROSOFT_SHAREPOINT_CLIENT_ID", + "MICROSOFT_SHAREPOINT_CLIENT_SECRET"} = + SetupUtils.env_vars_for(:microsoft_sharepoint) + end + end + + describe "demo_client_name/1" do + test "returns human-readable names with correct casing" do + assert SetupUtils.demo_client_name(:google_drive) == "Google Drive" + assert SetupUtils.demo_client_name(:google_sheets) == "Google Sheets" + assert SetupUtils.demo_client_name(:gmail) == "Gmail" + assert SetupUtils.demo_client_name(:salesforce) == "Salesforce" + + assert SetupUtils.demo_client_name(:salesforce_sandbox) == + "Salesforce Sandbox" + + # Tricky casing: SharePoint, OneDrive + assert SetupUtils.demo_client_name(:microsoft_sharepoint) == + "Microsoft SharePoint" + + assert SetupUtils.demo_client_name(:microsoft_onedrive) == + "Microsoft OneDrive" + + assert SetupUtils.demo_client_name(:microsoft_teams) == + "Microsoft Teams" + end + end + describe "list_demo_oauth_clients/0" do test "returns all clients as configured when env vars are set" do with_oauth_config() @@ -332,6 +387,34 @@ defmodule Lightning.SetupUtilsTest do assert cred.project_credentials == [] end + test "credential token body contains expected fields and uses client scopes" do + %{super_user: user} = + SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() + + clients = + SetupUtils.create_demo_oauth_clients(user, only: [:google_sheets]) + + {:ok, cred} = + SetupUtils.create_dummy_oauth_credential(clients.google_sheets, user) + + cred = Lightning.Repo.preload(cred, [:oauth_client, :credential_bodies]) + + assert [body] = cred.credential_bodies + decoded = body.body + + assert %{ + "access_token" => "placeholder_replace_by_reauthorizing", + "refresh_token" => "placeholder_replace_by_reauthorizing", + "token_type" => "Bearer", + "expires_in" => 3600, + "scope" => scope + } = decoded + + # The scope should come from the OAuth client's mandatory_scopes + assert scope == cred.oauth_client.mandatory_scopes + assert scope =~ "spreadsheets" + end + test "returns error for duplicate credential name" do %{super_user: user} = SetupUtils.create_users(create_super: true) |> SetupUtils.confirm_users() diff --git a/test/mix/tasks/create_oauth_credential_test.exs b/test/mix/tasks/create_oauth_credential_test.exs new file mode 100644 index 0000000000..5ec2d20aac --- /dev/null +++ b/test/mix/tasks/create_oauth_credential_test.exs @@ -0,0 +1,202 @@ +defmodule Mix.Tasks.Lightning.CreateOauthCredentialTest do + use Lightning.DataCase, async: false + + import ExUnit.CaptureIO + + alias Lightning.SetupUtils + + @test_oauth_config [ + google_drive: [client_id: "test-gd-id", client_secret: "test-gd-secret"], + google_sheets: [ + client_id: "test-gs-id", + client_secret: "test-gs-secret" + ], + gmail: [client_id: "test-gm-id", client_secret: "test-gm-secret"], + salesforce: [client_id: "test-sf-id", client_secret: "test-sf-secret"], + salesforce_sandbox: [ + client_id: "test-sfs-id", + client_secret: "test-sfs-secret" + ], + microsoft_sharepoint: [ + client_id: "test-sp-id", + client_secret: "test-sp-secret" + ], + microsoft_outlook: [ + client_id: "test-ol-id", + client_secret: "test-ol-secret" + ], + microsoft_calendar: [ + client_id: "test-cal-id", + client_secret: "test-cal-secret" + ], + microsoft_onedrive: [ + client_id: "test-od-id", + client_secret: "test-od-secret" + ], + microsoft_teams: [ + client_id: "test-tm-id", + client_secret: "test-tm-secret" + ] + ] + + defp with_oauth_config(config \\ @test_oauth_config) do + previous = Application.get_env(:lightning, :demo_oauth_clients) + Application.put_env(:lightning, :demo_oauth_clients, config) + + on_exit(fn -> + Application.put_env(:lightning, :demo_oauth_clients, previous) + end) + end + + defp create_user_and_clients do + with_oauth_config() + + %{super_user: user} = + SetupUtils.create_users(create_super: true) + |> SetupUtils.confirm_users() + + clients = + SetupUtils.create_demo_oauth_clients(user, only: [:gmail, :google_sheets]) + + %{user: user, clients: clients} + end + + describe "run/1 --list" do + test "shows available OAuth clients" do + %{clients: clients} = create_user_and_clients() + + output = + capture_io(fn -> + Mix.Tasks.Lightning.CreateOauthCredential.run(["--list"]) + end) + + assert output =~ "Available OAuth clients:" + assert output =~ clients.gmail.name + assert output =~ "ID:" + end + + test "shows empty message when no clients exist" do + output = + capture_io(fn -> + Mix.Tasks.Lightning.CreateOauthCredential.run(["--list"]) + end) + + assert output =~ "No OAuth clients found" + assert output =~ "setup_demo_oauth_clients" + end + end + + describe "run/1 creating credentials" do + test "creates credential by client name" do + %{clients: clients} = create_user_and_clients() + + output = + capture_io(fn -> + Mix.Tasks.Lightning.CreateOauthCredential.run([ + "--client", + clients.gmail.name + ]) + end) + + assert output =~ "Created credential: Gmail - demo" + assert output =~ "OAuth client: Gmail" + assert output =~ "placeholder tokens" + end + + test "creates credential with custom --name" do + %{clients: clients} = create_user_and_clients() + + output = + capture_io(fn -> + Mix.Tasks.Lightning.CreateOauthCredential.run([ + "--client", + clients.gmail.name, + "--name", + "My Custom Gmail" + ]) + end) + + assert output =~ "Created credential: My Custom Gmail" + end + + test "creates credential attached to --project" do + %{user: user, clients: clients} = create_user_and_clients() + + {:ok, project} = + Lightning.Projects.create_project(%{ + name: "test-project", + project_users: [%{user_id: user.id, role: :owner}] + }) + + output = + capture_io(fn -> + Mix.Tasks.Lightning.CreateOauthCredential.run([ + "--client", + clients.gmail.name, + "--project", + project.id + ]) + end) + + assert output =~ "Created credential:" + assert output =~ "Project: #{project.id}" + end + + test "raises when --client is missing" do + assert_raise Mix.Error, ~r/--client is required/, fn -> + Mix.Tasks.Lightning.CreateOauthCredential.run([]) + end + end + + test "raises when client not found and shows available clients" do + %{clients: _clients} = create_user_and_clients() + + error = + assert_raise Mix.Error, ~r/No OAuth client found/, fn -> + capture_io(fn -> + Mix.Tasks.Lightning.CreateOauthCredential.run([ + "--client", + "Nonexistent Service" + ]) + end) + end + + assert error.message =~ "Available OAuth clients:" + assert error.message =~ "Gmail" + end + + test "raises when no users exist" do + # With no clients and no users, client lookup fails first. + # The error indicates no clients exist yet. + assert_raise Mix.Error, ~r/No OAuth client found/, fn -> + capture_io(fn -> + Mix.Tasks.Lightning.CreateOauthCredential.run([ + "--client", + "anything" + ]) + end) + end + end + + test "raises when --user email not found" do + %{clients: clients} = create_user_and_clients() + + assert_raise Mix.Error, ~r/User not found with email/, fn -> + capture_io(fn -> + Mix.Tasks.Lightning.CreateOauthCredential.run([ + "--client", + clients.gmail.name, + "--user", + "nonexistent@example.com" + ]) + end) + end + end + + test "raises on unknown options" do + assert_raise Mix.Error, ~r/Unknown option/, fn -> + Mix.Tasks.Lightning.CreateOauthCredential.run(["--bogus"]) + end + end + end +end diff --git a/test/mix/tasks/setup_demo_oauth_clients_test.exs b/test/mix/tasks/setup_demo_oauth_clients_test.exs new file mode 100644 index 0000000000..1a960fad02 --- /dev/null +++ b/test/mix/tasks/setup_demo_oauth_clients_test.exs @@ -0,0 +1,159 @@ +defmodule Mix.Tasks.Lightning.SetupDemoOauthClientsTest do + use Lightning.DataCase, async: false + + import ExUnit.CaptureIO + + alias Lightning.SetupUtils + + @test_oauth_config [ + google_drive: [client_id: "test-gd-id", client_secret: "test-gd-secret"], + google_sheets: [ + client_id: "test-gs-id", + client_secret: "test-gs-secret" + ], + gmail: [client_id: "test-gm-id", client_secret: "test-gm-secret"], + salesforce: [client_id: "test-sf-id", client_secret: "test-sf-secret"], + salesforce_sandbox: [ + client_id: "test-sfs-id", + client_secret: "test-sfs-secret" + ], + microsoft_sharepoint: [ + client_id: "test-sp-id", + client_secret: "test-sp-secret" + ], + microsoft_outlook: [ + client_id: "test-ol-id", + client_secret: "test-ol-secret" + ], + microsoft_calendar: [ + client_id: "test-cal-id", + client_secret: "test-cal-secret" + ], + microsoft_onedrive: [ + client_id: "test-od-id", + client_secret: "test-od-secret" + ], + microsoft_teams: [ + client_id: "test-tm-id", + client_secret: "test-tm-secret" + ] + ] + + defp with_oauth_config(config \\ @test_oauth_config) do + previous = Application.get_env(:lightning, :demo_oauth_clients) + Application.put_env(:lightning, :demo_oauth_clients, config) + + on_exit(fn -> + Application.put_env(:lightning, :demo_oauth_clients, previous) + end) + end + + describe "run/1 --list" do + test "shows configured and not_configured clients" do + with_oauth_config(gmail: [client_id: "id", client_secret: "secret"]) + + output = + capture_io(fn -> + Mix.Tasks.Lightning.SetupDemoOauthClients.run(["--list"]) + end) + + assert output =~ "Ready to create:" + assert output =~ "gmail" + assert output =~ "Missing OAuth client configuration:" + assert output =~ "google_drive" + assert output =~ "1 configured, 9 missing configuration" + end + + test "shows all configured when all env vars set" do + with_oauth_config() + + output = + capture_io(fn -> + Mix.Tasks.Lightning.SetupDemoOauthClients.run(["--list"]) + end) + + assert output =~ "Ready to create:" + assert output =~ "10 configured, 0 missing configuration" + refute output =~ "Missing OAuth client configuration:" + end + end + + describe "run/1 creating clients" do + setup do + with_oauth_config() + end + + test "creates all configured OAuth clients" do + %{super_user: _user} = + SetupUtils.create_users(create_super: true) + |> SetupUtils.confirm_users() + + output = + capture_io(fn -> + Mix.Tasks.Lightning.SetupDemoOauthClients.run([]) + end) + + assert output =~ "Created:" + assert output =~ "Google Sheets" + assert output =~ "Gmail" + assert output =~ "Salesforce" + assert output =~ "Created 10, skipped 0, not configured 0" + end + + test "--only filters to specified clients" do + %{super_user: _user} = + SetupUtils.create_users(create_super: true) + |> SetupUtils.confirm_users() + + output = + capture_io(fn -> + Mix.Tasks.Lightning.SetupDemoOauthClients.run([ + "--only", + "google_sheets,salesforce" + ]) + end) + + assert output =~ "Google Sheets" + assert output =~ "Salesforce" + assert output =~ "Created 2, skipped 0, not configured 0" + end + + test "--only with invalid key raises" do + assert_raise Mix.Error, ~r/Unknown client key/, fn -> + Mix.Tasks.Lightning.SetupDemoOauthClients.run([ + "--only", + "bogus_service" + ]) + end + end + + test "raises on unknown options" do + assert_raise Mix.Error, ~r/Unknown option/, fn -> + Mix.Tasks.Lightning.SetupDemoOauthClients.run(["--foo"]) + end + end + + test "raises when no users exist" do + assert_raise Mix.Error, ~r/No users found/, fn -> + capture_io(fn -> + Mix.Tasks.Lightning.SetupDemoOauthClients.run([]) + end) + end + end + + test "raises when --user email not found" do + %{super_user: _user} = + SetupUtils.create_users(create_super: true) + |> SetupUtils.confirm_users() + + assert_raise Mix.Error, ~r/User not found with email/, fn -> + capture_io(fn -> + Mix.Tasks.Lightning.SetupDemoOauthClients.run([ + "--user", + "nonexistent@example.com" + ]) + end) + end + end + end +end