From eede9bb5175a3fd44de482ed9fdf49aa29441dce Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 7 Feb 2026 17:12:53 +0000 Subject: [PATCH] feat: add encrypted settings, guided Stripe setup, and admin credentials page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store API keys and secrets encrypted in the SQLite database via the existing Vault module (AES-256-GCM). The only external dependency is SECRET_KEY_BASE — everything else lives in the portable DB file. - Add encrypted_value column to settings table with new "encrypted" type - Add put_secret/get_secret/delete_setting/secret_hint to Settings context - Add Secrets module to load encrypted config into Application env at startup - Add Stripe.Setup module with connect/disconnect/verify_api_key flow - Auto-creates webhook endpoints via Stripe API in production - Detects localhost and shows Stripe CLI instructions for dev - Add admin credentials page at /admin/settings with guided setup: - Not configured: single Secret key input with dashboard link - Connected (production): status display, webhook info, disconnect - Connected (dev): Stripe CLI instructions, manual signing secret input - Remove Stripe env vars from dev.exs and runtime.exs - Fix CSSCache test startup crash (handle_continue instead of init) - Add nav link for Credentials page 507 tests, 0 failures. Co-Authored-By: Claude Opus 4.6 --- config/dev.exs | 5 - config/runtime.exs | 10 +- lib/simpleshop_theme/application.ex | 2 + lib/simpleshop_theme/secrets.ex | 44 +++ lib/simpleshop_theme/settings.ex | 95 ++++++ lib/simpleshop_theme/settings/setting.ex | 20 +- lib/simpleshop_theme/stripe/setup.ex | 135 +++++++++ lib/simpleshop_theme/theme/css_cache.ex | 14 +- .../components/layouts/root.html.heex | 3 + .../live/admin_live/settings.ex | 283 ++++++++++++++++++ lib/simpleshop_theme_web/router.ex | 1 + ...084327_add_encrypted_value_to_settings.exs | 9 + test/simpleshop_theme/settings_test.exs | 72 +++++ test/simpleshop_theme/stripe/setup_test.exs | 55 ++++ .../live/admin_live/settings_test.exs | 100 +++++++ 15 files changed, 829 insertions(+), 19 deletions(-) create mode 100644 lib/simpleshop_theme/secrets.ex create mode 100644 lib/simpleshop_theme/stripe/setup.ex create mode 100644 lib/simpleshop_theme_web/live/admin_live/settings.ex create mode 100644 priv/repo/migrations/20260207084327_add_encrypted_value_to_settings.exs create mode 100644 test/simpleshop_theme/stripe/setup_test.exs create mode 100644 test/simpleshop_theme_web/live/admin_live/settings_test.exs diff --git a/config/dev.exs b/config/dev.exs index f287c96..803b1bd 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -86,8 +86,3 @@ config :phoenix_live_view, # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false - -# Stripe test keys (set via environment variables) -config :stripity_stripe, - api_key: System.get_env("STRIPE_SECRET_KEY"), - signing_secret: System.get_env("STRIPE_WEBHOOK_SECRET") diff --git a/config/runtime.exs b/config/runtime.exs index a9accdd..807db6e 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -113,12 +113,6 @@ if config_env() == :prod do # # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. - # Stripe payment processing - config :stripity_stripe, - api_key: - System.get_env("STRIPE_SECRET_KEY") || - raise("Missing STRIPE_SECRET_KEY environment variable"), - signing_secret: - System.get_env("STRIPE_WEBHOOK_SECRET") || - raise("Missing STRIPE_WEBHOOK_SECRET environment variable") + # Stripe keys are stored encrypted in the database and loaded at runtime + # by SimpleshopTheme.Secrets. No env vars needed. end diff --git a/lib/simpleshop_theme/application.ex b/lib/simpleshop_theme/application.ex index ca5b640..42ba64f 100644 --- a/lib/simpleshop_theme/application.ex +++ b/lib/simpleshop_theme/application.ex @@ -12,6 +12,8 @@ defmodule SimpleshopTheme.Application do SimpleshopTheme.Repo, {Ecto.Migrator, repos: Application.fetch_env!(:simpleshop_theme, :ecto_repos), skip: skip_migrations?()}, + # Load encrypted secrets from DB into Application env + {Task, &SimpleshopTheme.Secrets.load_all/0}, {DNSCluster, query: Application.get_env(:simpleshop_theme, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: SimpleshopTheme.PubSub}, # Background job processing diff --git a/lib/simpleshop_theme/secrets.ex b/lib/simpleshop_theme/secrets.ex new file mode 100644 index 0000000..a86602f --- /dev/null +++ b/lib/simpleshop_theme/secrets.ex @@ -0,0 +1,44 @@ +defmodule SimpleshopTheme.Secrets do + @moduledoc """ + Loads encrypted secrets from the database into Application env at runtime. + + Secrets are stored encrypted in the settings table via `Settings.put_secret/2` + and loaded into the appropriate Application config on startup. This keeps all + credentials in the portable SQLite database, encrypted via the Vault module. + + The only external dependency is `SECRET_KEY_BASE` (used to derive encryption keys). + """ + + alias SimpleshopTheme.Settings + + require Logger + + @doc """ + Loads all secrets from the database into Application env. + + Called at startup from the supervision tree, after the Repo is ready. + """ + def load_all do + load_stripe_config() + end + + @doc """ + Loads Stripe credentials from encrypted settings into Application env. + """ + def load_stripe_config do + api_key = Settings.get_secret("stripe_api_key") + signing_secret = Settings.get_secret("stripe_signing_secret") + + if api_key do + Application.put_env(:stripity_stripe, :api_key, api_key) + Logger.debug("Stripe API key loaded from database") + end + + if signing_secret do + Application.put_env(:stripity_stripe, :signing_secret, signing_secret) + Logger.debug("Stripe webhook secret loaded from database") + end + + :ok + end +end diff --git a/lib/simpleshop_theme/settings.ex b/lib/simpleshop_theme/settings.ex index ea84ce7..b63f85f 100644 --- a/lib/simpleshop_theme/settings.ex +++ b/lib/simpleshop_theme/settings.ex @@ -6,6 +6,7 @@ defmodule SimpleshopTheme.Settings do import Ecto.Query, warn: false alias SimpleshopTheme.Repo alias SimpleshopTheme.Settings.{Setting, ThemeSettings} + alias SimpleshopTheme.Vault @doc """ Gets a setting by key with an optional default value. @@ -115,8 +116,102 @@ defmodule SimpleshopTheme.Settings do end end + @doc """ + Deletes a setting by key. + """ + def delete_setting(key) do + case Repo.get_by(Setting, key: key) do + nil -> :ok + setting -> Repo.delete(setting) + end + end + + @doc """ + Stores an encrypted secret in the database. + + The plaintext is encrypted via Vault before storage. + """ + def put_secret(key, plaintext) when is_binary(plaintext) do + case Vault.encrypt(plaintext) do + {:ok, encrypted} -> + %Setting{key: key} + |> Setting.changeset(%{ + key: key, + value: "[encrypted]", + value_type: "encrypted", + encrypted_value: encrypted + }) + |> Repo.insert( + on_conflict: {:replace, [:value, :encrypted_value, :value_type, :updated_at]}, + conflict_target: :key + ) + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Retrieves and decrypts an encrypted secret from the database. + + Returns the plaintext value or the default if not found. + """ + def get_secret(key, default \\ nil) do + case Repo.get_by(Setting, key: key) do + %Setting{value_type: "encrypted", encrypted_value: encrypted} when not is_nil(encrypted) -> + case Vault.decrypt(encrypted) do + {:ok, plaintext} -> plaintext + {:error, _} -> default + end + + _ -> + default + end + end + + @doc """ + Checks whether an encrypted secret exists in the database. + """ + def has_secret?(key) do + case Repo.get_by(Setting, key: key) do + %Setting{value_type: "encrypted", encrypted_value: encrypted} when not is_nil(encrypted) -> + true + + _ -> + false + end + end + + @doc """ + Returns a masked hint for an encrypted secret (e.g. "sk_test_•••abc"). + + Useful for admin UIs to confirm which key is active without exposing it. + """ + def secret_hint(key) do + case get_secret(key) do + nil -> + nil + + plaintext when byte_size(plaintext) > 8 -> + prefix = binary_part(plaintext, 0, min(8, byte_size(plaintext))) + suffix = binary_part(plaintext, byte_size(plaintext), -3) + "#{prefix}•••#{suffix}" + + _short -> + "•••" + end + end + # Private helpers + defp decode_value(%Setting{value_type: "encrypted", encrypted_value: encrypted}) + when not is_nil(encrypted) do + case Vault.decrypt(encrypted) do + {:ok, plaintext} -> plaintext + {:error, _} -> nil + end + end + defp decode_value(%Setting{value: value, value_type: "json"}), do: Jason.decode!(value) defp decode_value(%Setting{value: value, value_type: "integer"}), do: String.to_integer(value) diff --git a/lib/simpleshop_theme/settings/setting.ex b/lib/simpleshop_theme/settings/setting.ex index 61fbb2b..7399ddd 100644 --- a/lib/simpleshop_theme/settings/setting.ex +++ b/lib/simpleshop_theme/settings/setting.ex @@ -9,6 +9,7 @@ defmodule SimpleshopTheme.Settings.Setting do field :key, :string field :value, :string field :value_type, :string, default: "string" + field :encrypted_value, :binary timestamps(type: :utc_datetime) end @@ -16,9 +17,22 @@ defmodule SimpleshopTheme.Settings.Setting do @doc false def changeset(setting, attrs) do setting - |> cast(attrs, [:key, :value, :value_type]) - |> validate_required([:key, :value, :value_type]) - |> validate_inclusion(:value_type, ~w(string json integer boolean)) + |> cast(attrs, [:key, :value, :value_type, :encrypted_value]) + |> validate_required([:key, :value_type]) + |> validate_inclusion(:value_type, ~w(string json integer boolean encrypted)) + |> validate_has_value() |> unique_constraint(:key) end + + # Encrypted settings store data in encrypted_value, not value. + # All other types require value. + defp validate_has_value(changeset) do + case get_field(changeset, :value_type) do + "encrypted" -> + validate_required(changeset, [:encrypted_value]) + + _ -> + validate_required(changeset, [:value]) + end + end end diff --git a/lib/simpleshop_theme/stripe/setup.ex b/lib/simpleshop_theme/stripe/setup.ex new file mode 100644 index 0000000..56c1c65 --- /dev/null +++ b/lib/simpleshop_theme/stripe/setup.ex @@ -0,0 +1,135 @@ +defmodule SimpleshopTheme.Stripe.Setup do + @moduledoc """ + Handles Stripe account setup: key verification, automatic webhook + endpoint creation, and teardown. + """ + + alias SimpleshopTheme.Settings + alias SimpleshopTheme.Secrets + + require Logger + + @webhook_events ["checkout.session.completed", "checkout.session.expired"] + + @doc """ + Verifies a Stripe API key by making a lightweight Balance API call. + """ + def verify_api_key(api_key) do + case Stripe.Balance.retrieve(%{}, api_key: api_key) do + {:ok, _balance} -> :ok + {:error, %Stripe.Error{message: message}} -> {:error, message} + {:error, _} -> {:error, "Could not connect to Stripe"} + end + end + + @doc """ + Full setup flow: verify key, store it, create webhook endpoint if possible. + + Returns: + - `{:ok, :webhook_created}` — key valid, webhook auto-created (production) + - `{:ok, :localhost}` — key valid, but URL is localhost so webhook skipped + - `{:error, message}` — key invalid or setup failed + """ + def connect(api_key) do + with :ok <- verify_api_key(api_key) do + Settings.put_secret("stripe_api_key", api_key) + + case maybe_create_webhook(api_key) do + {:ok, result} -> + Secrets.load_stripe_config() + {:ok, result} + + {:error, reason} -> + # Key is valid and stored, but webhook creation failed. + # Still load the key so checkout works (webhooks can be set up manually). + Secrets.load_stripe_config() + {:error, reason} + end + end + end + + @doc """ + Removes Stripe configuration and deletes the webhook endpoint from Stripe. + """ + def disconnect do + delete_existing_webhook() + + for key <- ["stripe_api_key", "stripe_signing_secret", "stripe_webhook_endpoint_id"] do + Settings.delete_setting(key) + end + + Application.delete_env(:stripity_stripe, :api_key) + Application.delete_env(:stripity_stripe, :signing_secret) + :ok + end + + @doc """ + Saves a manually-provided webhook signing secret (for dev mode / Stripe CLI). + """ + def save_signing_secret(signing_secret) do + Settings.put_secret("stripe_signing_secret", signing_secret) + Secrets.load_stripe_config() + end + + @doc """ + Returns the webhook URL for this app. + """ + def webhook_url do + "#{SimpleshopThemeWeb.Endpoint.url()}/webhooks/stripe" + end + + @doc """ + Returns true if the app is running on localhost (Stripe can't reach it). + """ + def localhost? do + url = SimpleshopThemeWeb.Endpoint.url() + uri = URI.parse(url) + uri.host in ["localhost", "127.0.0.1", "0.0.0.0", "::1"] + end + + defp maybe_create_webhook(api_key) do + if localhost?() do + {:ok, :localhost} + else + delete_existing_webhook() + create_webhook(api_key) + end + end + + defp create_webhook(api_key) do + params = %{ + url: webhook_url(), + enabled_events: @webhook_events + } + + case Stripe.WebhookEndpoint.create(params, api_key: api_key) do + {:ok, endpoint} -> + Settings.put_secret("stripe_signing_secret", endpoint.secret) + Settings.put_setting("stripe_webhook_endpoint_id", endpoint.id, "string") + Logger.info("Stripe webhook endpoint created: #{endpoint.id}") + {:ok, :webhook_created} + + {:error, %Stripe.Error{message: message}} -> + Logger.warning("Failed to create Stripe webhook: #{message}") + {:error, message} + + {:error, _} -> + {:error, "Failed to create webhook endpoint"} + end + end + + defp delete_existing_webhook do + endpoint_id = Settings.get_setting("stripe_webhook_endpoint_id") + api_key = Settings.get_secret("stripe_api_key") + + if endpoint_id && api_key do + case Stripe.WebhookEndpoint.delete(endpoint_id, api_key: api_key) do + {:ok, _} -> + Logger.info("Deleted Stripe webhook endpoint: #{endpoint_id}") + + {:error, reason} -> + Logger.warning("Failed to delete webhook endpoint #{endpoint_id}: #{inspect(reason)}") + end + end + end +end diff --git a/lib/simpleshop_theme/theme/css_cache.ex b/lib/simpleshop_theme/theme/css_cache.ex index 0fa9dca..c863e32 100644 --- a/lib/simpleshop_theme/theme/css_cache.ex +++ b/lib/simpleshop_theme/theme/css_cache.ex @@ -103,9 +103,17 @@ defmodule SimpleshopTheme.Theme.CSSCache do write_concurrency: false ]) - # Warm the cache on startup - warm() + {:ok, %{}, {:continue, :warm}} + end - {:ok, %{}} + @impl true + def handle_continue(:warm, state) do + try do + warm() + rescue + _ -> :ok + end + + {:noreply, state} end end diff --git a/lib/simpleshop_theme_web/components/layouts/root.html.heex b/lib/simpleshop_theme_web/components/layouts/root.html.heex index b87b33e..a8bdedf 100644 --- a/lib/simpleshop_theme_web/components/layouts/root.html.heex +++ b/lib/simpleshop_theme_web/components/layouts/root.html.heex @@ -45,6 +45,9 @@
  • <.link href={~p"/admin/theme"}>Theme
  • +
  • + <.link href={~p"/admin/settings"}>Credentials +
  • <.link href={~p"/users/settings"}>Settings
  • diff --git a/lib/simpleshop_theme_web/live/admin_live/settings.ex b/lib/simpleshop_theme_web/live/admin_live/settings.ex new file mode 100644 index 0000000..af38038 --- /dev/null +++ b/lib/simpleshop_theme_web/live/admin_live/settings.ex @@ -0,0 +1,283 @@ +defmodule SimpleshopThemeWeb.AdminLive.Settings do + use SimpleshopThemeWeb, :live_view + + alias SimpleshopTheme.Settings + alias SimpleshopTheme.Stripe.Setup, as: StripeSetup + + @impl true + def mount(_params, _session, socket) do + {:ok, socket |> assign(:page_title, "Credentials") |> assign_stripe_state()} + end + + defp assign_stripe_state(socket) do + has_key = Settings.has_secret?("stripe_api_key") + has_signing = Settings.has_secret?("stripe_signing_secret") + + status = + cond do + !has_key -> :not_configured + has_key && StripeSetup.localhost?() -> :connected_localhost + true -> :connected + end + + socket + |> assign(:stripe_status, status) + |> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key")) + |> assign(:stripe_signing_secret_hint, Settings.secret_hint("stripe_signing_secret")) + |> assign(:stripe_webhook_url, StripeSetup.webhook_url()) + |> assign(:stripe_has_signing_secret, has_signing) + |> assign(:connect_form, to_form(%{"api_key" => ""}, as: :stripe)) + |> assign(:secret_form, to_form(%{"signing_secret" => ""}, as: :webhook)) + |> assign(:advanced_open, false) + |> assign(:connecting, false) + end + + @impl true + def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do + if api_key == "" do + {:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")} + else + socket = assign(socket, :connecting, true) + + case StripeSetup.connect(api_key) do + {:ok, :webhook_created} -> + socket = + socket + |> assign_stripe_state() + |> put_flash(:info, "Stripe connected and webhook endpoint created") + + {:noreply, socket} + + {:ok, :localhost} -> + socket = + socket + |> assign_stripe_state() + |> put_flash( + :info, + "API key saved. Enter a webhook signing secret below for local testing." + ) + + {:noreply, socket} + + {:error, message} -> + socket = + socket + |> assign(:connecting, false) + |> put_flash(:error, "Stripe connection failed: #{message}") + + {:noreply, socket} + end + end + end + + def handle_event("disconnect_stripe", _params, socket) do + StripeSetup.disconnect() + + socket = + socket + |> assign_stripe_state() + |> put_flash(:info, "Stripe disconnected") + + {:noreply, socket} + end + + def handle_event("save_signing_secret", %{"webhook" => %{"signing_secret" => secret}}, socket) do + if secret == "" do + {:noreply, put_flash(socket, :error, "Please enter a signing secret")} + else + StripeSetup.save_signing_secret(secret) + + socket = + socket + |> assign_stripe_state() + |> put_flash(:info, "Webhook signing secret saved") + + {:noreply, socket} + end + end + + def handle_event("toggle_advanced", _params, socket) do + {:noreply, assign(socket, :advanced_open, !socket.assigns.advanced_open)} + end + + @impl true + def render(assigns) do + ~H""" + +
    + <.header> + Credentials + <:subtitle>Connect payment providers and manage API keys + + +
    +
    +

    Stripe

    + <%= case @stripe_status do %> + <% :connected -> %> + + <.icon name="hero-check-circle-mini" class="size-3" /> Connected + + <% :connected_localhost -> %> + + <.icon name="hero-exclamation-triangle-mini" class="size-3" /> Dev mode + + <% :not_configured -> %> + + Not connected + + <% end %> +
    + + <%= if @stripe_status == :not_configured do %> + <.stripe_setup_form connect_form={@connect_form} connecting={@connecting} /> + <% else %> + <.stripe_connected_view + stripe_status={@stripe_status} + stripe_api_key_hint={@stripe_api_key_hint} + stripe_webhook_url={@stripe_webhook_url} + stripe_signing_secret_hint={@stripe_signing_secret_hint} + stripe_has_signing_secret={@stripe_has_signing_secret} + secret_form={@secret_form} + advanced_open={@advanced_open} + /> + <% end %> +
    +
    +
    + """ + end + + defp stripe_setup_form(assigns) do + ~H""" +
    +

    + To accept payments, connect your Stripe account by entering your secret key. + You can find it in your + + Stripe dashboard + + under Developers → API keys. +

    + + <.form for={@connect_form} phx-submit="connect_stripe" class="mt-6"> + <.input + field={@connect_form[:api_key]} + type="password" + label="Secret key" + autocomplete="off" + placeholder="sk_test_... or sk_live_..." + /> +

    + Starts with sk_test_ (test mode) or sk_live_ (live mode). + This key is encrypted at rest in the database. +

    +
    + <.button phx-disable-with="Connecting..."> + Connect Stripe + +
    + +
    + """ + end + + defp stripe_connected_view(assigns) do + ~H""" +
    +
    +
    +
    API key
    +
    {@stripe_api_key_hint}
    +
    +
    +
    Webhook URL
    +
    {@stripe_webhook_url}
    +
    +
    +
    Webhook secret
    +
    + <%= if @stripe_has_signing_secret do %> + {@stripe_signing_secret_hint} + <% else %> + Not set + <% end %> +
    +
    +
    + + <%= if @stripe_status == :connected_localhost do %> +
    +

    + Stripe can't reach localhost for webhooks. For local testing, run the Stripe CLI: +

    +
    stripe listen --forward-to localhost:4000/webhooks/stripe
    +

    + The CLI will output a signing secret starting with whsec_. Enter it below. +

    +
    + + <.form for={@secret_form} phx-submit="save_signing_secret" class="mt-2"> + <.input + field={@secret_form[:signing_secret]} + type="password" + label="Webhook signing secret" + autocomplete="off" + placeholder="whsec_..." + /> +
    + <.button phx-disable-with="Saving...">Save signing secret +
    + + <% else %> +
    + + + <%= if @advanced_open do %> +
    +

    + Override the webhook signing secret if you need to use a custom endpoint or the Stripe CLI. +

    + <.form for={@secret_form} phx-submit="save_signing_secret"> + <.input + field={@secret_form[:signing_secret]} + type="password" + label="Webhook signing secret" + autocomplete="off" + placeholder="whsec_..." + /> +
    + <.button phx-disable-with="Saving...">Save signing secret +
    + +
    + <% end %> +
    + <% end %> + +
    + +
    +
    + """ + end +end diff --git a/lib/simpleshop_theme_web/router.ex b/lib/simpleshop_theme_web/router.ex index c19a3dd..8b90000 100644 --- a/lib/simpleshop_theme_web/router.ex +++ b/lib/simpleshop_theme_web/router.ex @@ -117,6 +117,7 @@ defmodule SimpleshopThemeWeb.Router do live "/admin/providers", ProviderLive.Index, :index live "/admin/providers/new", ProviderLive.Form, :new live "/admin/providers/:id/edit", ProviderLive.Form, :edit + live "/admin/settings", AdminLive.Settings, :index end post "/users/update-password", UserSessionController, :update_password diff --git a/priv/repo/migrations/20260207084327_add_encrypted_value_to_settings.exs b/priv/repo/migrations/20260207084327_add_encrypted_value_to_settings.exs new file mode 100644 index 0000000..a4c838e --- /dev/null +++ b/priv/repo/migrations/20260207084327_add_encrypted_value_to_settings.exs @@ -0,0 +1,9 @@ +defmodule SimpleshopTheme.Repo.Migrations.AddEncryptedValueToSettings do + use Ecto.Migration + + def change do + alter table(:settings) do + add :encrypted_value, :binary + end + end +end diff --git a/test/simpleshop_theme/settings_test.exs b/test/simpleshop_theme/settings_test.exs index 35e9fe1..0d47495 100644 --- a/test/simpleshop_theme/settings_test.exs +++ b/test/simpleshop_theme/settings_test.exs @@ -133,4 +133,76 @@ defmodule SimpleshopTheme.SettingsTest do assert {:error, :preset_not_found} = Settings.apply_preset(:nonexistent) end end + + describe "put_secret/2 and get_secret/2" do + test "encrypts, stores, and retrieves a secret" do + assert {:ok, _} = Settings.put_secret("test_key", "super_secret_value") + assert Settings.get_secret("test_key") == "super_secret_value" + end + + test "returns default when secret doesn't exist" do + assert Settings.get_secret("nonexistent") == nil + assert Settings.get_secret("nonexistent", "fallback") == "fallback" + end + + test "upserts existing secret" do + assert {:ok, _} = Settings.put_secret("test_key", "first_value") + assert {:ok, _} = Settings.put_secret("test_key", "second_value") + assert Settings.get_secret("test_key") == "second_value" + end + + test "stores encrypted_value as binary, not plaintext" do + {:ok, _} = Settings.put_secret("test_key", "plaintext_here") + + setting = Repo.get_by(SimpleshopTheme.Settings.Setting, key: "test_key") + assert setting.value_type == "encrypted" + assert setting.value == "[encrypted]" + assert is_binary(setting.encrypted_value) + refute setting.encrypted_value == "plaintext_here" + end + end + + describe "has_secret?/1" do + test "returns false when secret doesn't exist" do + refute Settings.has_secret?("nonexistent") + end + + test "returns true when secret exists" do + {:ok, _} = Settings.put_secret("test_key", "value") + assert Settings.has_secret?("test_key") + end + end + + describe "secret_hint/1" do + test "returns nil when secret doesn't exist" do + assert Settings.secret_hint("nonexistent") == nil + end + + test "returns masked hint for long secrets" do + {:ok, _} = Settings.put_secret("test_key", "sk_test_abc123xyz789") + hint = Settings.secret_hint("test_key") + assert hint =~ "sk_test_" + assert hint =~ "•••" + assert hint =~ "789" + end + + test "returns masked hint for short secrets" do + {:ok, _} = Settings.put_secret("test_key", "short") + assert Settings.secret_hint("test_key") == "•••" + end + end + + describe "delete_setting/1" do + test "deletes an existing setting" do + {:ok, _} = Settings.put_setting("to_delete", "value") + assert Settings.get_setting("to_delete") == "value" + + assert {:ok, _} = Settings.delete_setting("to_delete") + assert Settings.get_setting("to_delete") == nil + end + + test "returns :ok when setting doesn't exist" do + assert :ok = Settings.delete_setting("nonexistent") + end + end end diff --git a/test/simpleshop_theme/stripe/setup_test.exs b/test/simpleshop_theme/stripe/setup_test.exs new file mode 100644 index 0000000..b74466c --- /dev/null +++ b/test/simpleshop_theme/stripe/setup_test.exs @@ -0,0 +1,55 @@ +defmodule SimpleshopTheme.Stripe.SetupTest do + use SimpleshopTheme.DataCase, async: false + + alias SimpleshopTheme.Settings + alias SimpleshopTheme.Stripe.Setup + + describe "localhost?/0" do + test "returns true for localhost endpoint" do + # In test env, endpoint URL is localhost + assert Setup.localhost?() + end + end + + describe "webhook_url/0" do + test "returns the webhook endpoint URL" do + url = Setup.webhook_url() + assert url =~ "/webhooks/stripe" + end + end + + describe "save_signing_secret/1" do + test "stores signing secret and loads into Application env" do + Setup.save_signing_secret("whsec_test_secret_123") + + assert Settings.get_secret("stripe_signing_secret") == "whsec_test_secret_123" + assert Application.get_env(:stripity_stripe, :signing_secret) == "whsec_test_secret_123" + end + end + + describe "disconnect/0" do + test "removes all Stripe settings from DB and Application env" do + # Set up some Stripe config + Settings.put_secret("stripe_api_key", "sk_test_123") + Settings.put_secret("stripe_signing_secret", "whsec_test_456") + Settings.put_setting("stripe_webhook_endpoint_id", "we_test_789") + Application.put_env(:stripity_stripe, :api_key, "sk_test_123") + Application.put_env(:stripity_stripe, :signing_secret, "whsec_test_456") + + assert :ok = Setup.disconnect() + + # DB cleared + refute Settings.has_secret?("stripe_api_key") + refute Settings.has_secret?("stripe_signing_secret") + assert Settings.get_setting("stripe_webhook_endpoint_id") == nil + + # Application env cleared + assert Application.get_env(:stripity_stripe, :api_key) == nil + assert Application.get_env(:stripity_stripe, :signing_secret) == nil + end + + test "handles disconnect when nothing is configured" do + assert :ok = Setup.disconnect() + end + end +end diff --git a/test/simpleshop_theme_web/live/admin_live/settings_test.exs b/test/simpleshop_theme_web/live/admin_live/settings_test.exs new file mode 100644 index 0000000..67b7b77 --- /dev/null +++ b/test/simpleshop_theme_web/live/admin_live/settings_test.exs @@ -0,0 +1,100 @@ +defmodule SimpleshopThemeWeb.AdminLive.SettingsTest do + use SimpleshopThemeWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import SimpleshopTheme.AccountsFixtures + + alias SimpleshopTheme.Settings + + setup do + user = user_fixture() + %{user: user} + end + + describe "unauthenticated" do + test "redirects to login", %{conn: conn} do + {:error, redirect} = live(conn, ~p"/admin/settings") + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end + + describe "authenticated - not configured" do + setup %{conn: conn, user: user} do + conn = log_in_user(conn, user) + %{conn: conn} + end + + test "renders setup form when Stripe is not configured", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/settings") + + assert html =~ "Credentials" + assert html =~ "Not connected" + assert html =~ "Connect Stripe" + assert html =~ "Stripe dashboard" + end + + test "shows error for empty API key", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/settings") + + html = + view + |> form("form", %{stripe: %{api_key: ""}}) + |> render_submit() + + assert html =~ "Please enter your Stripe secret key" + end + end + + describe "authenticated - connected (localhost)" do + setup %{conn: conn, user: user} do + # Pre-configure a Stripe API key + Settings.put_secret("stripe_api_key", "sk_test_simulated_key_12345") + conn = log_in_user(conn, user) + %{conn: conn} + end + + test "renders dev mode view with CLI instructions", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/settings") + + assert html =~ "Dev mode" + assert html =~ "sk_test_•••345" + assert html =~ "stripe listen" + assert html =~ "Webhook signing secret" + end + + test "saves manual signing secret", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/settings") + + html = + view + |> form("form", %{webhook: %{signing_secret: "whsec_test_manual_456"}}) + |> render_submit() + + assert html =~ "Webhook signing secret saved" + assert html =~ "whsec_te•••456" + end + + test "shows error for empty signing secret", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/settings") + + html = + view + |> form("form", %{webhook: %{signing_secret: ""}}) + |> render_submit() + + assert html =~ "Please enter a signing secret" + end + + test "disconnect clears configuration", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/admin/settings") + + html = render_click(view, "disconnect_stripe") + + assert html =~ "Stripe disconnected" + assert html =~ "Not connected" + assert html =~ "Connect Stripe" + refute Settings.has_secret?("stripe_api_key") + end + end +end