add Printful webhook endpoint with token verification

New POST /webhooks/printful route with VerifyPrintfulWebhook plug
(shared secret token via header or query param). Handles package_shipped,
order_failed, order_canceled, product_updated, product_synced, and
product_deleted events. Webhook registration via Printful v2 API with
token appended to URL. 19 new tests (819 total).

Also marks task #28 as done — Printful sync products already include
preview mockup images handled by the existing ImageDownloadWorker
pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-15 09:32:14 +00:00
parent 0cfcb2448e
commit 24d61f7a9e
8 changed files with 519 additions and 8 deletions

View File

@@ -98,4 +98,81 @@ defmodule SimpleshopThemeWeb.WebhookControllerTest do
:crypto.mac(:hmac, :sha256, secret, body)
|> Base.encode16(case: :lower)
end
describe "POST /webhooks/printful" do
@printful_secret "printful_test_secret_456"
setup do
_conn =
provider_connection_fixture(%{
provider_type: "printful",
config: %{"store_id" => "99999", "webhook_secret" => @printful_secret}
})
:ok
end
test "returns 401 without token", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post(~p"/webhooks/printful", %{type: "product_updated"})
assert json_response(conn, 401)["error"] == "Invalid token"
end
test "returns 401 with wrong token", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pf-webhook-token", "wrong_token")
|> post(~p"/webhooks/printful", %{type: "product_updated"})
assert json_response(conn, 401)["error"] == "Invalid token"
end
test "accepts valid token via header", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pf-webhook-token", @printful_secret)
|> post(~p"/webhooks/printful", %{type: "product_updated", data: %{}})
assert json_response(conn, 200)["status"] == "ok"
end
test "accepts valid token via query param", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post(~p"/webhooks/printful?token=#{@printful_secret}", %{
type: "product_updated",
data: %{}
})
assert json_response(conn, 200)["status"] == "ok"
end
test "handles unknown event gracefully", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pf-webhook-token", @printful_secret)
|> post(~p"/webhooks/printful", %{type: "unknown_event", data: %{}})
assert json_response(conn, 200)["status"] == "ok"
end
test "returns 401 when no webhook secret configured", %{conn: conn} do
SimpleshopTheme.Repo.delete_all(SimpleshopTheme.Products.ProviderConnection)
conn =
conn
|> put_req_header("content-type", "application/json")
|> put_req_header("x-pf-webhook-token", @printful_secret)
|> post(~p"/webhooks/printful", %{type: "product_updated"})
assert json_response(conn, 401)["error"] == "Invalid token"
end
end
end