defmodule TarakanWeb.Webhooks.StripeControllerTest do use TarakanWeb.ConnCase, async: true alias Tarakan.Accounts.Scope alias Tarakan.Market.Bounty alias Tarakan.Repo @secret "whsec_test_secret" defp fiat_bounty do repository = listed_github_repository_fixture() sponsor = active_account_fixture() |> fund_credits(1_000) {bounty, _checkout_url} = fiat_bounty_fixture(Scope.for_account(sponsor), repository) bounty end defp signed_post(conn, payload, opts \\ []) do timestamp = Keyword.get(opts, :timestamp, System.system_time(:second)) secret = Keyword.get(opts, :secret, @secret) signature = :crypto.mac(:hmac, :sha256, secret, "#{timestamp}.#{payload}") |> Base.encode16(case: :lower) conn |> put_req_header("content-type", "application/json") |> put_req_header("stripe-signature", "t=#{timestamp},v1=#{signature}") |> post(~p"/webhooks/stripe", payload) end defp checkout_completed_payload(session_id) do Jason.encode!(%{ "id" => "evt_1", "type" => "checkout.session.completed", "data" => %{"object" => %{"id" => session_id, "payment_intent" => "pi_1"}} }) end test "a validly signed checkout.session.completed funds the bounty", %{conn: conn} do bounty = fiat_bounty() conn = signed_post(conn, checkout_completed_payload(bounty.stripe_checkout_session_id)) assert json_response(conn, 200) == %{"received" => true} assert Repo.get!(Bounty, bounty.id).status == "open" end defp checkout_payload(session_id, type, extra) do Jason.encode!(%{ "id" => "evt_1", "type" => type, "data" => %{"object" => Map.merge(%{"id" => session_id}, extra)} }) end test "a completed but unpaid session does not fund the bounty", %{conn: conn} do bounty = fiat_bounty() # Delayed-notification methods complete the session before the money # arrives. Funding here would let work be claimed against nothing. conn = signed_post( conn, checkout_payload( bounty.stripe_checkout_session_id, "checkout.session.completed", %{"payment_status" => "unpaid"} ) ) assert json_response(conn, 200) == %{"received" => true} assert Repo.get!(Bounty, bounty.id).status == "pending_funding" end test "the later async payment confirmation funds it", %{conn: conn} do bounty = fiat_bounty() conn = signed_post( conn, checkout_payload( bounty.stripe_checkout_session_id, "checkout.session.completed", %{"payment_status" => "unpaid"} ) ) assert json_response(conn, 200) == %{"received" => true} assert Repo.get!(Bounty, bounty.id).status == "pending_funding" conn = conn |> recycle() |> signed_post( checkout_payload( bounty.stripe_checkout_session_id, "checkout.session.async_payment_succeeded", %{} ) ) assert json_response(conn, 200) == %{"received" => true} assert Repo.get!(Bounty, bounty.id).status == "open" end test "a failed async payment leaves the bounty unfunded", %{conn: conn} do bounty = fiat_bounty() conn = signed_post( conn, checkout_payload( bounty.stripe_checkout_session_id, "checkout.session.async_payment_failed", %{} ) ) assert json_response(conn, 200) == %{"received" => true} assert Repo.get!(Bounty, bounty.id).status == "pending_funding" end test "an invalid signature is rejected", %{conn: conn} do bounty = fiat_bounty() conn = signed_post(conn, checkout_completed_payload(bounty.stripe_checkout_session_id), secret: "whsec_wrong" ) assert json_response(conn, 400) == %{"error" => "invalid_signature"} assert Repo.get!(Bounty, bounty.id).status == "pending_funding" end test "a stale timestamp is rejected", %{conn: conn} do bounty = fiat_bounty() conn = signed_post(conn, checkout_completed_payload(bounty.stripe_checkout_session_id), timestamp: System.system_time(:second) - 600 ) assert json_response(conn, 400) == %{"error" => "timestamp_outside_tolerance"} assert Repo.get!(Bounty, bounty.id).status == "pending_funding" end test "a missing signature header is rejected", %{conn: conn} do conn = conn |> put_req_header("content-type", "application/json") |> post(~p"/webhooks/stripe", checkout_completed_payload("cs_test_whatever")) assert json_response(conn, 400) == %{"error" => "missing_signature"} end test "a tampered body is rejected", %{conn: conn} do timestamp = System.system_time(:second) signature = :crypto.mac( :hmac, :sha256, @secret, "#{timestamp}.{\"type\":\"checkout.session.completed\"}" ) |> Base.encode16(case: :lower) conn = conn |> put_req_header("content-type", "application/json") |> put_req_header("stripe-signature", "t=#{timestamp},v1=#{signature}") |> post(~p"/webhooks/stripe", checkout_completed_payload("cs_test_tampered")) assert json_response(conn, 400) == %{"error" => "invalid_signature"} end test "unknown events are acknowledged and ignored", %{conn: conn} do payload = Jason.encode!(%{"id" => "evt_2", "type" => "payment_intent.created", "data" => %{}}) conn = signed_post(conn, payload) assert json_response(conn, 200) == %{"received" => true} end test "funding an unknown session still returns 200", %{conn: conn} do conn = signed_post(conn, checkout_completed_payload("cs_test_unknown")) assert json_response(conn, 200) == %{"received" => true} end describe "subscription events" do defp subscription_event_payload(type, object) do Jason.encode!(%{"id" => "evt_sub", "type" => type, "data" => %{"object" => object}}) end defp checkout_subscription_payload(account) do subscription_event_payload("checkout.session.completed", %{ "id" => "cs_test_sub", "mode" => "subscription", "client_reference_id" => Integer.to_string(account.id), "customer" => "cus_webhook_1", "subscription" => "sub_webhook_1", "metadata" => %{"plan" => "team"} }) end test "a signed subscription checkout activates the account's subscription", %{conn: conn} do account = account_fixture() conn = signed_post(conn, checkout_subscription_payload(account)) assert json_response(conn, 200) == %{"received" => true} subscription = Tarakan.Billing.subscription(account) assert subscription.status == "active" assert subscription.plan == "team" assert subscription.stripe_subscription_id == "sub_webhook_1" end test "customer.subscription.updated syncs the subscription", %{conn: conn} do account = account_fixture() conn = signed_post(conn, checkout_subscription_payload(account)) assert json_response(conn, 200) == %{"received" => true} payload = subscription_event_payload("customer.subscription.updated", %{ "id" => "sub_webhook_1", "status" => "active", "current_period_end" => 1_900_000_000, "items" => %{"data" => [%{"price" => %{"id" => "price_enterprise_test"}}]} }) conn = signed_post(build_conn(), payload) assert json_response(conn, 200) == %{"received" => true} subscription = Tarakan.Billing.subscription(account) assert subscription.plan == "enterprise" assert DateTime.to_unix(subscription.current_period_end) == 1_900_000_000 end test "invoice.payment_failed and customer.subscription.deleted move the status", %{ conn: conn } do account = account_fixture() conn = signed_post(conn, checkout_subscription_payload(account)) assert json_response(conn, 200) == %{"received" => true} failed = subscription_event_payload("invoice.payment_failed", %{ "subscription" => "sub_webhook_1" }) conn = signed_post(build_conn(), failed) assert json_response(conn, 200) == %{"received" => true} assert Tarakan.Billing.subscription(account).status == "past_due" deleted = subscription_event_payload("customer.subscription.deleted", %{ "id" => "sub_webhook_1" }) conn = signed_post(build_conn(), deleted) assert json_response(conn, 200) == %{"received" => true} assert Tarakan.Billing.subscription(account).status == "canceled" end test "subscription events require a valid signature", %{conn: conn} do account = account_fixture() conn = signed_post(conn, checkout_subscription_payload(account), secret: "whsec_wrong") assert json_response(conn, 400) == %{"error" => "invalid_signature"} assert Tarakan.Billing.subscription(account) == nil end end end