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 %>
+
+
+ <.icon
+ name={if @advanced_open, do: "hero-chevron-down-mini", else: "hero-chevron-right-mini"}
+ class="size-4"
+ /> Advanced
+
+
+ <%= 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 %>
+
+
+
+ Disconnect Stripe
+
+
+
+ """
+ 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