feat: add encrypted settings, guided Stripe setup, and admin credentials page
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
55
test/simpleshop_theme/stripe/setup_test.exs
Normal file
55
test/simpleshop_theme/stripe/setup_test.exs
Normal file
@@ -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
|
||||
100
test/simpleshop_theme_web/live/admin_live/settings_test.exs
Normal file
100
test/simpleshop_theme_web/live/admin_live/settings_test.exs
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user