feat: add Printify webhook endpoint for real-time product updates

- Add /webhooks/printify endpoint with HMAC-SHA256 signature verification
- Add Webhooks context to handle product:updated, product:deleted events
- Add ProductDeleteWorker for async product deletion
- Add webhook API methods to Printify client (create, list, delete)
- Add register_webhooks/2 to Printify provider
- Add mix register_webhooks task for one-time webhook registration
- Cache raw request body in endpoint for signature verification

Usage:
1. Generate webhook secret: openssl rand -hex 20
2. Add to provider connection config as "webhook_secret"
3. Register with Printify: mix register_webhooks https://yourshop.com/webhooks/printify

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-01-31 22:41:15 +00:00
parent a2157177b8
commit a9c15ea6ae
13 changed files with 596 additions and 4 deletions

View File

@@ -0,0 +1,101 @@
defmodule SimpleshopThemeWeb.WebhookControllerTest do
use SimpleshopThemeWeb.ConnCase
import SimpleshopTheme.ProductsFixtures
@webhook_secret "test_webhook_secret_123"
setup do
_conn =
provider_connection_fixture(%{
provider_type: "printify",
config: %{"shop_id" => "12345", "webhook_secret" => @webhook_secret}
})
:ok
end
describe "POST /webhooks/printify" do
test "returns 401 without signature", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post(~p"/webhooks/printify", %{type: "product:updated"})
assert json_response(conn, 401)["error"] == "Invalid signature"
end
test "returns 401 with invalid signature", %{conn: conn} do
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123"}})
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pfy-signature", "sha256=invalid")
|> post(~p"/webhooks/printify", body)
assert json_response(conn, 401)["error"] == "Invalid signature"
end
test "accepts valid signature and returns 200", %{conn: conn} do
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123", shop_id: "12345"}})
signature = compute_signature(body, @webhook_secret)
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|> post(~p"/webhooks/printify", body)
assert json_response(conn, 200)["status"] == "ok"
end
test "handles product:updated event", %{conn: conn} do
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123", shop_id: "12345"}})
signature = compute_signature(body, @webhook_secret)
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|> post(~p"/webhooks/printify", body)
# Should return 200 even if job processing fails (inline mode tries to execute)
assert json_response(conn, 200)["status"] == "ok"
end
test "handles product:deleted event", %{conn: conn} do
body = Jason.encode!(%{type: "product:deleted", resource: %{id: "456"}})
signature = compute_signature(body, @webhook_secret)
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|> post(~p"/webhooks/printify", body)
assert json_response(conn, 200)["status"] == "ok"
end
test "returns 401 when no webhook secret configured", %{conn: conn} do
# Remove the provider connection to simulate no secret
SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection)
body = Jason.encode!(%{type: "product:updated", resource: %{id: "123"}})
signature = compute_signature(body, @webhook_secret)
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pfy-signature", "sha256=#{signature}")
|> post(~p"/webhooks/printify", body)
assert json_response(conn, 401)["error"] == "Invalid signature"
end
end
defp compute_signature(body, secret) do
:crypto.mac(:hmac, :sha256, secret, body)
|> Base.encode16(case: :lower)
end
end