diff --git a/Makefile b/Makefile index a659308034d..7d1081cb29c 100644 --- a/Makefile +++ b/Makefile @@ -576,7 +576,8 @@ TEST_INTEGRATIONS_70 := \ test_integrations_phpredis5 \ test_integrations_predis_1 \ test_integrations_ratchet \ - test_integrations_sqlsrv + test_integrations_sqlsrv \ + test_integrations_stripe_latest TEST_WEB_70 := \ test_metrics \ @@ -620,6 +621,7 @@ TEST_INTEGRATIONS_71 := \ test_integrations_predis_1 \ test_integrations_ratchet \ test_integrations_sqlsrv \ + test_integrations_stripe_latest \ test_opentracing_10 TEST_WEB_71 := \ @@ -676,6 +678,7 @@ TEST_INTEGRATIONS_72 := \ test_integrations_predis_latest \ test_integrations_ratchet \ test_integrations_sqlsrv \ + test_integrations_stripe_latest \ test_opentracing_10 TEST_WEB_72 := \ @@ -738,6 +741,7 @@ TEST_INTEGRATIONS_73 :=\ test_integrations_predis_latest \ test_integrations_ratchet \ test_integrations_sqlsrv \ + test_integrations_stripe_latest \ test_opentracing_10 TEST_WEB_73 := \ @@ -802,6 +806,7 @@ TEST_INTEGRATIONS_74 := \ test_integrations_ratchet \ test_integrations_roadrunner \ test_integrations_sqlsrv \ + test_integrations_stripe_latest \ test_opentracing_10 TEST_WEB_74 := \ @@ -869,6 +874,7 @@ TEST_INTEGRATIONS_80 := \ test_integrations_predis_latest \ test_integrations_ratchet \ test_integrations_sqlsrv \ + test_integrations_stripe_latest \ test_integrations_swoole_5 \ test_opentracing_10 @@ -925,6 +931,7 @@ TEST_INTEGRATIONS_81 := \ test_integrations_predis_latest \ test_integrations_ratchet \ test_integrations_sqlsrv \ + test_integrations_stripe_latest \ test_integrations_swoole_5 \ test_opentracing_10 @@ -970,6 +977,7 @@ TEST_INTEGRATIONS_82 := \ test_integrations_monolog_latest \ test_integrations_mysqli \ test_integrations_openai_latest \ + test_integrations_stripe_latest \ test_opentelemetry_1 \ test_opentelemetry_beta \ test_integrations_googlespanner_latest \ @@ -1037,6 +1045,7 @@ TEST_INTEGRATIONS_83 := \ test_integrations_monolog_latest \ test_integrations_mysqli \ test_integrations_openai_latest \ + test_integrations_stripe_latest \ test_opentelemetry_1 \ test_opentelemetry_beta \ test_integrations_googlespanner_latest \ @@ -1099,6 +1108,7 @@ TEST_INTEGRATIONS_84 := \ test_integrations_monolog_latest \ test_integrations_mysqli \ test_integrations_openai_latest \ + test_integrations_stripe_latest \ test_opentelemetry_1 \ test_integrations_googlespanner_latest \ test_integrations_guzzle_latest \ @@ -1146,6 +1156,7 @@ TEST_INTEGRATIONS_85 := \ test_integrations_monolog_latest \ test_integrations_mysqli \ test_integrations_openai_latest \ + test_integrations_stripe_latest \ test_opentelemetry_1 \ test_integrations_guzzle_latest \ test_integrations_pcntl \ @@ -1393,6 +1404,8 @@ test_integrations_openai_latest: global_test_run_dependencies tests/Integrations $(eval TELEMETRY_ENABLED=1) $(call run_tests_debug,tests/Integrations/OpenAI/Latest) $(eval TELEMETRY_ENABLED=0) +test_integrations_stripe_latest: global_test_run_dependencies tests/Integrations/Stripe/Latest/composer.lock-php$(PHP_MAJOR_MINOR) + $(call run_tests_debug,tests/Integrations/Stripe/Latest) test_integrations_pcntl: global_test_run_dependencies $(call run_tests_debug,tests/Integrations/PCNTL) test_integrations_pdo: global_test_run_dependencies diff --git a/appsec/tests/integration/build.gradle b/appsec/tests/integration/build.gradle index 10be6deffd5..1107937674c 100644 --- a/appsec/tests/integration/build.gradle +++ b/appsec/tests/integration/build.gradle @@ -451,7 +451,7 @@ testMatrix.each { spec -> dependsOn "buildTracer-${phpVersion}-${variant}" dependsOn "buildAppsec-${phpVersion}-${variant}" - if (version in ['7.0', '7.1']) { + if (phpVersion in ['7.0', '7.1']) { dependsOn downloadComposerOld } else { dependsOn downloadComposer diff --git a/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/mock_stripe/MockStripeServer.groovy b/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/mock_stripe/MockStripeServer.groovy new file mode 100644 index 00000000000..fb716f5197c --- /dev/null +++ b/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/mock_stripe/MockStripeServer.groovy @@ -0,0 +1,174 @@ +package com.datadog.appsec.php.mock_stripe + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import io.javalin.Javalin +import io.javalin.http.Context +import groovy.json.JsonOutput +import org.testcontainers.lifecycle.Startable + +@Slf4j +@CompileStatic +class MockStripeServer implements Startable { + Javalin httpServer + + @Override + void start() { + this.httpServer = Javalin.create(config -> { + config.showJavalinBanner = false + }) + + httpServer.post("/v1/checkout/sessions") { ctx -> + handleCheckoutSessionCreate(ctx) + } + + httpServer.post("/v1/payment_intents") { ctx -> + handlePaymentIntentCreate(ctx) + } + + httpServer.error(404, ctx -> { + log.info("Unmatched Stripe mock request: ${ctx.method()} ${ctx.path()}") + ctx.status(404).json(['StripeMock error': 'Not Found']) + }) + + httpServer.error(405, ctx -> { + ctx.status(405).json(['StripeMock error': 'Method Not Allowed']) + }) + + httpServer.start(0) + } + + int getPort() { + this.httpServer.port() + } + + @Override + void stop() { + if (httpServer != null) { + this.httpServer.stop() + this.httpServer = null + } + } + + private void handleCheckoutSessionCreate(Context ctx) { + def mode = ctx.formParam("mode") + def clientRefId = ctx.formParam("client_reference_id") + + ctx.status(200).json([ + id: "cs_test_" + System.currentTimeMillis(), + object: "checkout.session", + mode: mode ?: "payment", + amount_total: 1000, + currency: "usd", + client_reference_id: clientRefId ?: "test_ref", + livemode: false, + payment_status: "unpaid", + status: "open", + discounts: [ + [ + coupon: "SUMMER20", + promotion_code: "promo_123" + ] + ], + total_details: [ + amount_discount: 200, + amount_shipping: 500, + amount_tax: 0 + ] + ]) + } + + private void handlePaymentIntentCreate(Context ctx) { + def amountStr = ctx.formParam("amount") + def currency = ctx.formParam("currency") + + ctx.status(200).json([ + id: "pi_test_" + System.currentTimeMillis(), + object: "payment_intent", + amount: amountStr ? Integer.parseInt(amountStr) : 2000, + currency: currency ?: "usd", + livemode: false, + payment_method: "pm_test_123", + status: "requires_payment_method" + ]) + } + + static Map createWebhookSuccessEvent() { + return [ + id: "evt_test_success_${System.currentTimeMillis()}", + object: "event", + type: "payment_intent.succeeded", + data: [ + object: [ + id: "pi_test_success_123", + object: "payment_intent", + amount: 2000, + currency: "usd", + livemode: false, + payment_method: "pm_test_123", + status: "succeeded" + ] + ] + ] + } + + static Map createWebhookFailureEvent() { + return [ + id: "evt_test_failure_${System.currentTimeMillis()}", + object: "event", + type: "payment_intent.payment_failed", + data: [ + object: [ + id: "pi_test_failure_456", + object: "payment_intent", + amount: 1500, + currency: "eur", + livemode: false, + last_payment_error: [ + code: "card_declined", + decline_code: "insufficient_funds", + payment_method: [ + id: "pm_test_456", + type: "card" + ] + ], + status: "requires_payment_method" + ] + ] + ] + } + + static Map createWebhookCancellationEvent() { + return [ + id: "evt_test_cancel_${System.currentTimeMillis()}", + object: "event", + type: "payment_intent.canceled", + data: [ + object: [ + id: "pi_test_cancel_789", + object: "payment_intent", + amount: 3000, + currency: "gbp", + livemode: false, + cancellation_reason: "requested_by_customer", + status: "canceled" + ] + ] + ] + } + + static Map createWebhookUnsupportedEvent() { + return [ + id: "evt_test_unsupported_${System.currentTimeMillis()}", + object: "event", + type: "customer.created", + data: [ + object: [ + id: "cus_test_123", + object: "customer", + email: "test@example.com" + ] + ] + ] + } +} diff --git a/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/PaymentEventsTests.groovy b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/PaymentEventsTests.groovy new file mode 100644 index 00000000000..56521e364b4 --- /dev/null +++ b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/PaymentEventsTests.groovy @@ -0,0 +1,228 @@ +package com.datadog.appsec.php.integration + +import com.datadog.appsec.php.docker.AppSecContainer +import com.datadog.appsec.php.docker.FailOnUnmatchedTraces +import com.datadog.appsec.php.mock_stripe.MockStripeServer +import com.datadog.appsec.php.docker.InspectContainerHelper +import com.datadog.appsec.php.model.Span +import com.datadog.appsec.php.model.Trace +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import org.junit.jupiter.api.condition.EnabledIf +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers + +import java.io.InputStream + +import static org.testcontainers.containers.Container.ExecResult +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +import static com.datadog.appsec.php.integration.TestParams.getPhpVersion +import static com.datadog.appsec.php.integration.TestParams.getVariant +import static com.datadog.appsec.php.integration.TestParams.phpVersionAtLeast +import com.datadog.appsec.php.TelemetryHelpers +import static java.net.http.HttpResponse.BodyHandlers.ofString + +@Testcontainers +@EnabledIf('isExpectedVersion') +class PaymentEventsTests { + static boolean expectedVersion = !variant.contains('zts') + + AppSecContainer getContainer() { + getClass().CONTAINER + } + + public static final MockStripeServer mockStripeServer = new MockStripeServer() + + @Container + @FailOnUnmatchedTraces + public static final AppSecContainer CONTAINER = + new AppSecContainer( + workVolume: this.name, + baseTag: 'apache2-mod-php', + phpVersion: phpVersion, + phpVariant: variant, + www: 'payment', + ) { + { + dependsOn mockStripeServer + } + + @Override + void configure() { + super.configure() + org.testcontainers.Testcontainers.exposeHostPorts(mockStripeServer.port) + withEnv('STRIPE_API_BASE', "http://host.testcontainers.internal:${mockStripeServer.port}") + } + } + + static void main(String[] args) { + InspectContainerHelper.run(CONTAINER) + } + + static void assertPaymentCreationSpan(Trace trace) { + Span span = trace.first() + assert span.meta.'appsec.events.payments.integration' == 'stripe' + } + + static void assertPaymentWebhookSpan(Trace trace, String eventType) { + Span span = trace.first() + assert span.meta.'appsec.events.payments.integration' == 'stripe' + } + + @Test + void 'test checkout session creation with payment mode'() { + def trace = container.traceFromRequest("/payment.php?action=checkout_session") { HttpResponse resp -> + assert resp.statusCode() == 200 + def body = new String(resp.body().readAllBytes()) + assert body.contains('success') + assert body.contains('session_id') + } + assertPaymentCreationSpan(trace) + + Span span = trace.first() + assert span.meta.'appsec.events.payments.creation.id' ==~ /^cs_test_\d+$/ + assert span.metrics.'appsec.events.payments.creation.amount_total' == 1000 + assert span.meta.'appsec.events.payments.creation.currency' == 'usd' + } + + @Test + void 'test checkout session with non-payment mode should be ignored'() { + def trace = container.traceFromRequest("/payment.php?action=checkout_session_subscription") { HttpResponse resp -> + assert resp.statusCode() == 200 + def body = new String(resp.body().readAllBytes()) + assert body.contains('success') + } + + Span span = trace.first() + assert !span.meta.containsKey('appsec.events.payments.integration') || + span.meta.'appsec.events.payments.integration' == null + } + + @Test + void 'test payment intent creation'() { + def trace = container.traceFromRequest("/payment.php?action=payment_intent") { HttpResponse resp -> + assert resp.statusCode() == 200 + def body = new String(resp.body().readAllBytes()) + assert body.contains('success') + assert body.contains('payment_intent_id') + } + assertPaymentCreationSpan(trace) + + Span span = trace.first() + assert span.meta.'appsec.events.payments.creation.id' ==~ /^pi_test_\d+$/ + assert span.metrics.'appsec.events.payments.creation.amount' == 2000 + assert span.meta.'appsec.events.payments.creation.currency' == 'usd' + } + + @Test + void 'test payment success webhook'() { + def trace = container.traceFromRequest("/payment.php?action=webhook_success") { HttpResponse resp -> + assert resp.statusCode() == 200 + def body = new String(resp.body().readAllBytes()) + assert body.contains('success') + assert body.contains('payment_intent.succeeded') + } + assertPaymentWebhookSpan(trace, 'success') + + Span span = trace.first() + assert span.meta.'appsec.events.payments.success.id' == 'pi_test_success_123' + assert span.metrics.'appsec.events.payments.success.amount' == 2000 + assert span.meta.'appsec.events.payments.success.currency' == 'usd' + assert span.meta.'appsec.events.payments.success.payment_method' == 'pm_test_123' + } + + @Test + void 'test payment failure webhook'() { + def trace = container.traceFromRequest("/payment.php?action=webhook_failure") { HttpResponse resp -> + assert resp.statusCode() == 200 + def body = new String(resp.body().readAllBytes()) + assert body.contains('success') + assert body.contains('payment_intent.payment_failed') + } + assertPaymentWebhookSpan(trace, 'failure') + + Span span = trace.first() + assert span.meta.'appsec.events.payments.failure.id' == 'pi_test_failure_456' + assert span.metrics.'appsec.events.payments.failure.amount' == 1500 + assert span.meta.'appsec.events.payments.failure.currency' == 'eur' + assert span.meta.'appsec.events.payments.failure.last_payment_error.code' == 'card_declined' + assert span.meta.'appsec.events.payments.failure.last_payment_error.decline_code' == 'insufficient_funds' + } + + @Test + void 'test payment cancellation webhook'() { + def trace = container.traceFromRequest("/payment.php?action=webhook_cancellation") { HttpResponse resp -> + assert resp.statusCode() == 200 + def body = new String(resp.body().readAllBytes()) + assert body.contains('success') + assert body.contains('payment_intent.canceled') + } + assertPaymentWebhookSpan(trace, 'cancellation') + + Span span = trace.first() + assert span.meta.'appsec.events.payments.cancellation.id' == 'pi_test_cancel_789' + assert span.metrics.'appsec.events.payments.cancellation.amount' == 3000 + assert span.meta.'appsec.events.payments.cancellation.currency' == 'gbp' + assert span.meta.'appsec.events.payments.cancellation.cancellation_reason' == 'requested_by_customer' + } + + @Test + void 'test unsupported event type is ignored'() { + def trace = container.traceFromRequest("/payment.php?action=webhook_unsupported") { HttpResponse resp -> + assert resp.statusCode() == 200 + def body = new String(resp.body().readAllBytes()) + assert body.contains('success') + assert body.contains('customer.created') + } + + Span span = trace.first() + assert !span.meta.containsKey('appsec.events.payments.integration') || + span.meta.'appsec.events.payments.integration' == null + } + + @Test + void 'test checkout session creation with direct method call'() { + def trace = container.traceFromRequest("/payment.php?action=checkout_session_direct") { HttpResponse resp -> + assert resp.statusCode() == 200 + def body = new String(resp.body().readAllBytes()) + assert body.contains('success') + assert body.contains('session_id') + } + assertPaymentCreationSpan(trace) + + Span span = trace.first() + assert span.meta.'appsec.events.payments.creation.id' ==~ /^cs_test_\d+$/ + assert span.metrics.'appsec.events.payments.creation.amount_total' == 1000 + assert span.meta.'appsec.events.payments.creation.currency' == 'usd' + assert span.meta.'appsec.events.payments.creation.client_reference_id' == 'test_ref_direct_456' + } + + @Test + void 'test payment intent creation with direct method call'() { + def trace = container.traceFromRequest("/payment.php?action=payment_intent_direct") { HttpResponse resp -> + assert resp.statusCode() == 200 + def body = new String(resp.body().readAllBytes()) + assert body.contains('success') + assert body.contains('payment_intent_id') + } + assertPaymentCreationSpan(trace) + + Span span = trace.first() + assert span.meta.'appsec.events.payments.creation.id' ==~ /^pi_test_\d+$/ + assert span.metrics.'appsec.events.payments.creation.amount' == 3500 + assert span.meta.'appsec.events.payments.creation.currency' == 'eur' + } + + @Test + void 'Root has no Payment tags'() { + def trace = container.traceFromRequest('/hello.php') { HttpResponse resp -> + assert resp.statusCode() == 200 + } + Span span = trace.first() + assert !span.meta.containsKey('appsec.events.payments.integration') + assert !span.meta.containsKey('appsec.events.payments.creation.id') + } +} diff --git a/appsec/tests/integration/src/test/waf/recommended.json b/appsec/tests/integration/src/test/waf/recommended.json index 2766a4a9e34..02ddabf0310 100644 --- a/appsec/tests/integration/src/test/waf/recommended.json +++ b/appsec/tests/integration/src/test/waf/recommended.json @@ -6977,6 +6977,340 @@ } } } + }, + { + "id": "api-100-001", + "name": "Stripe instrumentation: Payment creation", + "tags": { + "type": "ecommerce.payment.creation", + "category": "business_logic", + "module": "business-logic" + }, + "min_version": "1.25.0", + "conditions": [ + { + "operator": "equals", + "parameters": { + "inputs": [ + { + "address": "server.business_logic.payment.creation", + "key_path": [ + "integration" + ] + } + ], + "type": "string", + "value": "stripe" + } + } + ], + "transformers": [], + "output": { + "event": false, + "keep": false, + "attributes": { + "appsec.events.payments.integration": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "integration" + ] + }, + "appsec.events.payments.creation.id": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "id" + ] + }, + "appsec.events.payments.creation.amount_total": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "amount_total" + ] + }, + "appsec.events.payments.creation.client_reference_id": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "client_reference_id" + ] + }, + "appsec.events.payments.creation.currency": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "currency" + ] + }, + "appsec.events.payments.creation.discounts.coupon": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "discounts.coupon" + ] + }, + "appsec.events.payments.creation.discounts.promotion_code": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "discounts.promotion_code" + ] + }, + "appsec.events.payments.creation.livemode": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "livemode" + ] + }, + "appsec.events.payments.creation.total_details.amount_discount": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "total_details.amount_discount" + ] + }, + "appsec.events.payments.creation.total_details.amount_shipping": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "total_details.amount_shipping" + ] + }, + "appsec.events.payments.creation.amount": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "amount" + ] + }, + "appsec.events.payments.creation.payment_method": { + "address": "server.business_logic.payment.creation", + "key_path": [ + "payment_method" + ] + } + } + } + }, + { + "id": "api-100-002", + "name": "Stripe instrumentation: Payment success", + "tags": { + "type": "ecommerce.payment.success", + "category": "business_logic", + "module": "business-logic" + }, + "min_version": "1.25.0", + "conditions": [ + { + "operator": "equals", + "parameters": { + "inputs": [ + { + "address": "server.business_logic.payment.success", + "key_path": [ + "integration" + ] + } + ], + "type": "string", + "value": "stripe" + } + } + ], + "transformers": [], + "output": { + "event": false, + "keep": false, + "attributes": { + "appsec.events.payments.integration": { + "address": "server.business_logic.payment.success", + "key_path": [ + "integration" + ] + }, + "appsec.events.payments.success.id": { + "address": "server.business_logic.payment.success", + "key_path": [ + "id" + ] + }, + "appsec.events.payments.success.amount": { + "address": "server.business_logic.payment.success", + "key_path": [ + "amount" + ] + }, + "appsec.events.payments.success.currency": { + "address": "server.business_logic.payment.success", + "key_path": [ + "currency" + ] + }, + "appsec.events.payments.success.livemode": { + "address": "server.business_logic.payment.success", + "key_path": [ + "livemode" + ] + }, + "appsec.events.payments.success.payment_method": { + "address": "server.business_logic.payment.success", + "key_path": [ + "payment_method" + ] + } + } + } + }, + { + "id": "api-100-003", + "name": "Stripe instrumentation: Payment failure", + "tags": { + "type": "ecommerce.payment.failure", + "category": "business_logic", + "module": "business-logic" + }, + "min_version": "1.25.0", + "conditions": [ + { + "operator": "equals", + "parameters": { + "inputs": [ + { + "address": "server.business_logic.payment.failure", + "key_path": [ + "integration" + ] + } + ], + "type": "string", + "value": "stripe" + } + } + ], + "transformers": [], + "output": { + "event": false, + "keep": false, + "attributes": { + "appsec.events.payments.integration": { + "address": "server.business_logic.payment.failure", + "key_path": [ + "integration" + ] + }, + "appsec.events.payments.failure.id": { + "address": "server.business_logic.payment.failure", + "key_path": [ + "id" + ] + }, + "appsec.events.payments.failure.amount": { + "address": "server.business_logic.payment.failure", + "key_path": [ + "amount" + ] + }, + "appsec.events.payments.failure.currency": { + "address": "server.business_logic.payment.failure", + "key_path": [ + "currency" + ] + }, + "appsec.events.payments.failure.last_payment_error.code": { + "address": "server.business_logic.payment.failure", + "key_path": [ + "last_payment_error.code" + ] + }, + "appsec.events.payments.failure.last_payment_error.decline_code": { + "address": "server.business_logic.payment.failure", + "key_path": [ + "last_payment_error.decline_code" + ] + }, + "appsec.events.payments.failure.last_payment_error.payment_method.id": { + "address": "server.business_logic.payment.failure", + "key_path": [ + "last_payment_error.payment_method.id" + ] + }, + "appsec.events.payments.failure.last_payment_error.payment_method.type": { + "address": "server.business_logic.payment.failure", + "key_path": [ + "last_payment_error.payment_method.type" + ] + }, + "appsec.events.payments.failure.livemode": { + "address": "server.business_logic.payment.failure", + "key_path": [ + "livemode" + ] + } + } + } + }, + { + "id": "api-100-004", + "name": "Stripe instrumentation: Payment cancellation", + "tags": { + "type": "ecommerce.payment.cancellation", + "category": "business_logic", + "module": "business-logic" + }, + "min_version": "1.25.0", + "conditions": [ + { + "operator": "equals", + "parameters": { + "inputs": [ + { + "address": "server.business_logic.payment.cancellation", + "key_path": [ + "integration" + ] + } + ], + "type": "string", + "value": "stripe" + } + } + ], + "transformers": [], + "output": { + "event": false, + "keep": false, + "attributes": { + "appsec.events.payments.integration": { + "address": "server.business_logic.payment.cancellation", + "key_path": [ + "integration" + ] + }, + "appsec.events.payments.cancellation.id": { + "address": "server.business_logic.payment.cancellation", + "key_path": [ + "id" + ] + }, + "appsec.events.payments.cancellation.amount": { + "address": "server.business_logic.payment.cancellation", + "key_path": [ + "amount" + ] + }, + "appsec.events.payments.cancellation.cancellation_reason": { + "address": "server.business_logic.payment.cancellation", + "key_path": [ + "cancellation_reason" + ] + }, + "appsec.events.payments.cancellation.currency": { + "address": "server.business_logic.payment.cancellation", + "key_path": [ + "currency" + ] + }, + "appsec.events.payments.cancellation.livemode": { + "address": "server.business_logic.payment.cancellation", + "key_path": [ + "livemode" + ] + } + } + } } ], "rules_compat": [ diff --git a/appsec/tests/integration/src/test/www/payment/composer.json b/appsec/tests/integration/src/test/www/payment/composer.json new file mode 100644 index 00000000000..ebbe106857a --- /dev/null +++ b/appsec/tests/integration/src/test/www/payment/composer.json @@ -0,0 +1,8 @@ +{ + "name": "datadog/appsec-integration-tests", + "type": "project", + "require": { + "stripe/stripe-php": "^13.0", + "guzzlehttp/guzzle": "*" + } +} diff --git a/appsec/tests/integration/src/test/www/payment/initialize.sh b/appsec/tests/integration/src/test/www/payment/initialize.sh new file mode 100755 index 00000000000..d51f393acff --- /dev/null +++ b/appsec/tests/integration/src/test/www/payment/initialize.sh @@ -0,0 +1,18 @@ +#!/bin/bash -e + +cd /var/www + +export DD_TRACE_CLI_ENABLED=false + +# Install Composer if not already available +if ! command -v composer >/dev/null 2>&1; then + curl -sS https://getcomposer.org/installer -o composer-setup.php + php composer-setup.php --quiet + rm composer-setup.php + COMPOSER_BIN="php composer.phar" +else + COMPOSER_BIN="composer" +fi + +$COMPOSER_BIN install --no-dev +chown -R www-data.www-data vendor diff --git a/appsec/tests/integration/src/test/www/payment/public/hello.php b/appsec/tests/integration/src/test/www/payment/public/hello.php new file mode 100644 index 00000000000..fe7fb9a1a77 --- /dev/null +++ b/appsec/tests/integration/src/test/www/payment/public/hello.php @@ -0,0 +1,7 @@ + 'sk_test_fake_key_for_testing', + 'api_base' => $stripeApiBase + ]); + + $session = $client->checkout->sessions->create([ + 'mode' => 'payment', + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + 'line_items' => [ + [ + 'price_data' => [ + 'currency' => 'usd', + 'product_data' => ['name' => 'Test Product'], + 'unit_amount' => 1000, + ], + 'quantity' => 1, + ], + ], + 'client_reference_id' => 'test_ref_123', + ]); + + echo json_encode([ + 'status' => 'success', + 'action' => 'checkout_session', + 'session_id' => $session->id + ]); + break; + + case 'checkout_session_subscription': + $client = new \Stripe\StripeClient([ + 'api_key' => 'sk_test_fake_key_for_testing', + 'api_base' => $stripeApiBase + ]); + + $session = $client->checkout->sessions->create([ + 'mode' => 'subscription', + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + 'line_items' => [ + [ + 'price' => 'price_123', + 'quantity' => 1, + ], + ], + ]); + + echo json_encode([ + 'status' => 'success', + 'action' => 'checkout_session_subscription', + 'session_id' => $session->id + ]); + break; + + case 'payment_intent': + $client = new \Stripe\StripeClient([ + 'api_key' => 'sk_test_fake_key_for_testing', + 'api_base' => $stripeApiBase + ]); + + $paymentIntent = $client->paymentIntents->create([ + 'amount' => 2000, + 'currency' => 'usd', + 'payment_method_types' => ['card'], + ]); + + echo json_encode([ + 'status' => 'success', + 'action' => 'payment_intent', + 'payment_intent_id' => $paymentIntent->id + ]); + break; + + case 'webhook_success': + new \Stripe\StripeClient(['api_key' => 'sk_test_fake_key_for_testing']); + + $payload = json_encode([ + 'id' => 'evt_test_success_' . time(), + 'object' => 'event', + 'type' => 'payment_intent.succeeded', + 'data' => [ + 'object' => [ + 'id' => 'pi_test_success_123', + 'object' => 'payment_intent', + 'amount' => 2000, + 'currency' => 'usd', + 'livemode' => false, + 'payment_method' => 'pm_test_123', + 'status' => 'succeeded' + ] + ] + ]); + + $timestamp = time(); + $secret = 'whsec_test_secret'; + $signedPayload = $timestamp . '.' . $payload; + $signature = hash_hmac('sha256', $signedPayload, $secret); + $sigHeader = "t={$timestamp},v1={$signature}"; + + try { + $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret); + } catch (\Exception $e) { + $event = \Stripe\Event::constructFrom(json_decode($payload, true)); + } + + echo json_encode([ + 'status' => 'success', + 'action' => 'webhook_success', + 'event_id' => $event->id, + 'event_type' => $event->type, + 'debug' => 'webhook event constructed' + ]); + break; + + case 'webhook_failure': + new \Stripe\StripeClient(['api_key' => 'sk_test_fake_key_for_testing']); + + $payload = json_encode([ + 'id' => 'evt_test_failure_' . time(), + 'object' => 'event', + 'type' => 'payment_intent.payment_failed', + 'data' => [ + 'object' => [ + 'id' => 'pi_test_failure_456', + 'object' => 'payment_intent', + 'amount' => 1500, + 'currency' => 'eur', + 'livemode' => false, + 'last_payment_error' => [ + 'code' => 'card_declined', + 'decline_code' => 'insufficient_funds', + 'payment_method' => [ + 'id' => 'pm_test_456', + 'type' => 'card' + ] + ], + 'status' => 'requires_payment_method' + ] + ] + ]); + + $timestamp = time(); + $secret = 'whsec_test_secret'; + $signedPayload = $timestamp . '.' . $payload; + $signature = hash_hmac('sha256', $signedPayload, $secret); + $sigHeader = "t={$timestamp},v1={$signature}"; + + try { + $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret); + } catch (\Exception $e) { + $event = \Stripe\Event::constructFrom(json_decode($payload, true)); + } + + echo json_encode([ + 'status' => 'success', + 'action' => 'webhook_failure', + 'event_id' => $event->id, + 'event_type' => $event->type + ]); + break; + + case 'webhook_cancellation': + new \Stripe\StripeClient(['api_key' => 'sk_test_fake_key_for_testing']); + + $payload = json_encode([ + 'id' => 'evt_test_cancel_' . time(), + 'object' => 'event', + 'type' => 'payment_intent.canceled', + 'data' => [ + 'object' => [ + 'id' => 'pi_test_cancel_789', + 'object' => 'payment_intent', + 'amount' => 3000, + 'currency' => 'gbp', + 'livemode' => false, + 'cancellation_reason' => 'requested_by_customer', + 'status' => 'canceled' + ] + ] + ]); + + $timestamp = time(); + $secret = 'whsec_test_secret'; + $signedPayload = $timestamp . '.' . $payload; + $signature = hash_hmac('sha256', $signedPayload, $secret); + $sigHeader = "t={$timestamp},v1={$signature}"; + + try { + $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret); + } catch (\Exception $e) { + $event = \Stripe\Event::constructFrom(json_decode($payload, true)); + } + + echo json_encode([ + 'status' => 'success', + 'action' => 'webhook_cancellation', + 'event_id' => $event->id, + 'event_type' => $event->type + ]); + break; + + case 'webhook_unsupported': + new \Stripe\StripeClient(['api_key' => 'sk_test_fake_key_for_testing']); + + $payload = json_encode([ + 'id' => 'evt_test_unsupported_' . time(), + 'object' => 'event', + 'type' => 'customer.created', + 'data' => [ + 'object' => [ + 'id' => 'cus_test_123', + 'object' => 'customer', + 'email' => 'test@example.com' + ] + ] + ]); + + $timestamp = time(); + $secret = 'whsec_test_secret'; + $signedPayload = $timestamp . '.' . $payload; + $signature = hash_hmac('sha256', $signedPayload, $secret); + $sigHeader = "t={$timestamp},v1={$signature}"; + + try { + $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret); + } catch (\Exception $e) { + // Fallback to direct construction if webhook fails + $event = \Stripe\Event::constructFrom(json_decode($payload, true)); + } + + echo json_encode([ + 'status' => 'success', + 'action' => 'webhook_unsupported', + 'event_id' => $event->id, + 'event_type' => $event->type + ]); + break; + + case 'checkout_session_direct': + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $opts = ['api_base' => $stripeApiBase]; + + $session = \Stripe\Checkout\Session::create([ + 'mode' => 'payment', + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + 'line_items' => [ + [ + 'price_data' => [ + 'currency' => 'usd', + 'product_data' => ['name' => 'Test Product Direct'], + 'unit_amount' => 2500, + ], + 'quantity' => 2, + ], + ], + 'client_reference_id' => 'test_ref_direct_456', + ], $opts); + + echo json_encode([ + 'status' => 'success', + 'action' => 'checkout_session_direct', + 'session_id' => $session->id + ]); + break; + + case 'payment_intent_direct': + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $opts = ['api_base' => $stripeApiBase]; + + $paymentIntent = \Stripe\PaymentIntent::create([ + 'amount' => 3500, + 'currency' => 'eur', + 'payment_method_types' => ['card'], + ], $opts); + + echo json_encode([ + 'status' => 'success', + 'action' => 'payment_intent_direct', + 'payment_intent_id' => $paymentIntent->id + ]); + break; + + default: + echo json_encode([ + 'status' => 'error', + 'message' => 'Unknown action: ' . $action + ]); + } +} catch (Exception $e) { + echo json_encode([ + 'status' => 'error', + 'message' => $e->getMessage() + ]); +} diff --git a/ext/integrations/integrations.c b/ext/integrations/integrations.c index 3859c7aa74d..248e2cc7c45 100644 --- a/ext/integrations/integrations.c +++ b/ext/integrations/integrations.c @@ -442,6 +442,17 @@ void ddtrace_integrations_minit(void) { DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_SLIM, "Slim\\App", "__construct", "DDTrace\\Integrations\\Slim\\SlimIntegration"); + DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_STRIPE, "Stripe\\Stripe", "setApiKey", + "DDTrace\\Integrations\\Stripe\\StripeIntegration"); + DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_STRIPE, "Stripe\\StripeClient", "__construct", + "DDTrace\\Integrations\\Stripe\\StripeIntegration"); + DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_STRIPE, "Stripe\\Service\\Checkout\\SessionService", "create", + "DDTrace\\Integrations\\Stripe\\StripeIntegration"); + DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_STRIPE, "Stripe\\Service\\PaymentIntentService", "create", + "DDTrace\\Integrations\\Stripe\\StripeIntegration"); + DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_STRIPE, "Stripe\\Event", "constructFrom", + "DDTrace\\Integrations\\Stripe\\StripeIntegration"); + DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_SWOOLE, "Swoole\\Http\\Server", "__construct", "DDTrace\\Integrations\\Swoole\\SwooleIntegration"); diff --git a/ext/integrations/integrations.h b/ext/integrations/integrations.h index f8772463908..4cd30d5c823 100644 --- a/ext/integrations/integrations.h +++ b/ext/integrations/integrations.h @@ -48,6 +48,7 @@ INTEGRATION(ROADRUNNER, "roadrunner") \ INTEGRATION(SQLSRV, "sqlsrv") \ INTEGRATION(SLIM, "slim") \ + INTEGRATION(STRIPE, "stripe") \ INTEGRATION(SWOOLE, "swoole") \ INTEGRATION(SYMFONY, "symfony") \ INTEGRATION(SYMFONYMESSENGER, "symfonymessenger") \ diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 001ce65306d..dd41006ba47 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -2249,6 +2249,33 @@ "default": "true" } ], + "DD_TRACE_STRIPE_ANALYTICS_ENABLED": [ + { + "implementation": "B", + "type": "boolean", + "default": "false", + "aliases": [ + "DD_STRIPE_ANALYTICS_ENABLED" + ] + } + ], + "DD_TRACE_STRIPE_ANALYTICS_SAMPLE_RATE": [ + { + "implementation": "B", + "type": "decimal", + "default": "1.0", + "aliases": [ + "DD_STRIPE_ANALYTICS_SAMPLE_RATE" + ] + } + ], + "DD_TRACE_STRIPE_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "true" + } + ], "DD_TRACE_SWOOLE_ANALYTICS_ENABLED": [ { "implementation": "A", diff --git a/src/DDTrace/Integrations/Stripe/StripeIntegration.php b/src/DDTrace/Integrations/Stripe/StripeIntegration.php new file mode 100644 index 00000000000..d4aafb29994 --- /dev/null +++ b/src/DDTrace/Integrations/Stripe/StripeIntegration.php @@ -0,0 +1,223 @@ + $data]); + } + } + + public static function flattenFields($data, array $fieldPaths): array + { + $result = ['integration' => self::NAME]; + + foreach ($fieldPaths as $path) { + $value = self::getNestedValue($data, $path); + if ($value !== null) { + $result[$path] = $value; + } + } + + return $result; + } + + private static function getNestedValue($data, string $path) + { + $value = $data; + $keys = explode('.', $path); + + foreach ($keys as $key) { + if (is_array($value) && isset($value[$key])) { + $value = $value[$key]; + } elseif (is_object($value)) { + if (isset($value->$key)) { + $value = $value->$key; + } elseif (property_exists($value, $key)) { + $value = $value->$key; + } else { + return null; + } + } else { + return null; + } + } + + if (is_object($value) && method_exists($value, 'toArray')) { + return $value->toArray(); + } + + return $value; + } + + public static function extractCheckoutSessionFields($session): array + { + $fields = [ + 'id', + 'amount_total', + 'client_reference_id', + 'currency', + 'livemode', + 'total_details.amount_discount', + 'total_details.amount_shipping', + ]; + + $payload = self::flattenFields($session, $fields); + + $discounts = self::getNestedValue($session, 'discounts'); + if (is_array($discounts) && count($discounts) > 0) { + $discount = $discounts[0]; + $coupon = self::getNestedValue($discount, 'coupon'); + $promotionCode = self::getNestedValue($discount, 'promotion_code'); + if ($coupon !== null) { + $payload['discounts.coupon'] = $coupon; + } + if ($promotionCode !== null) { + $payload['discounts.promotion_code'] = $promotionCode; + } + } + + return $payload; + } + + public static function extractPaymentIntentFields($paymentIntent): array + { + return self::flattenFields($paymentIntent, [ + 'id', + 'amount', + 'currency', + 'livemode', + 'payment_method', + ]); + } + + public static function extractPaymentSuccessFields($eventData): array + { + return self::flattenFields($eventData, [ + 'id', + 'amount', + 'currency', + 'livemode', + 'payment_method', + ]); + } + + public static function extractPaymentFailureFields($eventData): array + { + return self::flattenFields($eventData, [ + 'id', + 'amount', + 'currency', + 'livemode', + 'last_payment_error.code', + 'last_payment_error.decline_code', + 'last_payment_error.payment_method.id', + 'last_payment_error.payment_method.type', + ]); + } + + public static function extractPaymentCancellationFields($eventData): array + { + return self::flattenFields($eventData, [ + 'id', + 'amount', + 'cancellation_reason', + 'currency', + 'livemode', + ]); + } + + public static function init(): int + { + self::hookCheckoutSessionCreate(); + self::hookPaymentIntentCreate(); + self::hookWebhookConstructEvent(); + + return Integration::LOADED; + } + + private static function hookCheckoutSessionCreate() + { + $onCreate = static function ($This, $scope, $args, $retval) { + if ($retval !== null && self::isCheckoutSessionPaymentMode($retval)) { + $payload = self::extractCheckoutSessionFields($retval); + self::pushPaymentEvent('server.business_logic.payment.creation', $payload); + } + }; + + \DDTrace\hook_method('Stripe\Service\Checkout\SessionService', 'create', null, $onCreate); + \DDTrace\hook_method('Stripe\Checkout\Session', 'create', null, $onCreate); + } + + private static function hookPaymentIntentCreate() + { + $onCreate = static function ($This, $scope, $args, $retval) { + if ($retval !== null) { + $payload = self::extractPaymentIntentFields($retval); + self::pushPaymentEvent('server.business_logic.payment.creation', $payload); + } + }; + + \DDTrace\hook_method('Stripe\Service\PaymentIntentService', 'create', null, $onCreate); + \DDTrace\hook_method('Stripe\PaymentIntent', 'create', null, $onCreate); + } + + private static function hookWebhookConstructEvent() + { + \DDTrace\hook_method( + 'Stripe\Webhook', + 'constructEvent', + null, + static function ($This, $scope, $args, $retval, $exception) { + if ($exception === null && $retval !== null) { + self::processWebhookEvent($retval); + } + } + ); + } + + private static function isCheckoutSessionPaymentMode($session): bool + { + $mode = self::getNestedValue($session, 'mode'); + return $mode === self::CHECKOUT_MODE_PAYMENT; + } + + private static function processWebhookEvent($event) + { + $eventType = self::getNestedValue($event, 'type'); + $eventObject = self::getNestedValue($event, 'data.object') ?? self::getNestedValue($event, 'object'); + + if ($eventType === null || $eventObject === null) { + return; + } + + switch ($eventType) { + case self::EVENT_PAYMENT_SUCCEEDED: + $payload = self::extractPaymentSuccessFields($eventObject); + self::pushPaymentEvent('server.business_logic.payment.success', $payload); + break; + case self::EVENT_PAYMENT_FAILED: + $payload = self::extractPaymentFailureFields($eventObject); + self::pushPaymentEvent('server.business_logic.payment.failure', $payload); + break; + case self::EVENT_PAYMENT_CANCELED: + $payload = self::extractPaymentCancellationFields($eventObject); + self::pushPaymentEvent('server.business_logic.payment.cancellation', $payload); + break; + default: + break; + } + } +} diff --git a/tests/Integrations/Stripe/Latest/StripeTest.php b/tests/Integrations/Stripe/Latest/StripeTest.php new file mode 100644 index 00000000000..3d2e8d4310b --- /dev/null +++ b/tests/Integrations/Stripe/Latest/StripeTest.php @@ -0,0 +1,593 @@ +setDefaults(); + $token = $this->generateToken(); + update_test_agent_session_token($token); + } + + protected function envsToCleanUpAtTearDown() + { + return [ + 'DD_STRIPE_SERVICE', + ]; + } + + private function findEventByKey(array $eventWrappers, string $key) + { + foreach ($eventWrappers as $eventWrapper) { + if (isset($eventWrapper[0][$key])) { + return $eventWrapper[0][$key]; + } + } + return null; + } + + private function hasEventWithAnyKey(array $eventWrappers, array $keys): bool + { + foreach ($eventWrappers as $eventWrapper) { + if (isset($eventWrapper[0])) { + $eventData = $eventWrapper[0]; + foreach ($keys as $key) { + if (isset($eventData[$key])) { + return true; + } + } + } + } + return false; + } + + private function constructWebhookEvent(array $payload, string $secret = 'whsec_test_secret'): \Stripe\Event + { + $payloadJson = json_encode($payload); + $timestamp = time(); + $signedPayload = $timestamp . '.' . $payloadJson; + $signature = hash_hmac('sha256', $signedPayload, $secret); + $sigHeader = "t={$timestamp},v1={$signature}"; + return \Stripe\Webhook::constructEvent($payloadJson, $sigHeader, $secret); + } + + protected function ddTearDown() + { + parent::ddTearDown(); + } + + public function testPaymentSuccessWebhook() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $payload = [ + 'id' => 'evt_test_webhook', + 'type' => 'payment_intent.succeeded', + 'data' => [ + 'object' => [ + 'id' => 'pi_test_success_123', + 'amount' => 2000, + 'currency' => 'usd', + 'livemode' => false, + 'payment_method' => 'pm_test_success_123', + ] + ] + ]; + + $this->constructWebhookEvent($payload); + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + + $this->assertNotEmpty($allEvents, 'Events should be captured by the hook'); + + $paymentEvent = $this->findEventByKey($allEvents, 'server.business_logic.payment.success'); + + $this->assertNotNull($paymentEvent, 'Payment success event should be found in captured events'); + + $this->assertEquals('stripe', $paymentEvent['integration'], 'Integration should be stripe'); + + $this->assertEquals('pi_test_success_123', $paymentEvent['id'], 'Payment intent ID should match'); + $this->assertEquals(2000, $paymentEvent['amount'], 'Amount should be 2000'); + $this->assertEquals('usd', $paymentEvent['currency'], 'Currency should be usd'); + $this->assertEquals(false, $paymentEvent['livemode'], 'Livemode should be false'); + $this->assertEquals('pm_test_success_123', $paymentEvent['payment_method'], 'Payment method should match'); + }); + } + + public function testPaymentFailureWebhook() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $payload = [ + 'id' => 'evt_test_webhook', + 'type' => 'payment_intent.payment_failed', + 'data' => [ + 'object' => [ + 'id' => 'pi_test_failure_456', + 'amount' => 1500, + 'currency' => 'eur', + 'livemode' => false, + 'last_payment_error' => [ + 'code' => 'card_declined', + 'decline_code' => 'insufficient_funds', + 'payment_method' => [ + 'id' => 'pm_test_failure_456', + 'type' => 'card', + ] + ] + ] + ] + ]; + + $this->constructWebhookEvent($payload); + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + + $this->assertNotEmpty($allEvents, 'Events should be captured by the hook'); + + $paymentEvent = $this->findEventByKey($allEvents, 'server.business_logic.payment.failure'); + + $this->assertNotNull($paymentEvent, 'Payment failure event should be found in captured events'); + + $this->assertEquals('stripe', $paymentEvent['integration'], 'Integration should be stripe'); + + $this->assertEquals('pi_test_failure_456', $paymentEvent['id'], 'Payment intent ID should match'); + $this->assertEquals(1500, $paymentEvent['amount'], 'Amount should be 1500'); + $this->assertEquals('eur', $paymentEvent['currency'], 'Currency should be eur'); + $this->assertEquals(false, $paymentEvent['livemode'], 'Livemode should be false'); + + $this->assertEquals('card_declined', $paymentEvent['last_payment_error.code'], 'Error code should be card_declined'); + $this->assertEquals('insufficient_funds', $paymentEvent['last_payment_error.decline_code'], 'Decline code should be insufficient_funds'); + $this->assertEquals('pm_test_failure_456', $paymentEvent['last_payment_error.payment_method.id'], 'Payment method ID should match'); + $this->assertEquals('card', $paymentEvent['last_payment_error.payment_method.type'], 'Payment method type should be card'); + }); + } + + public function testPaymentCancellationWebhook() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $payload = [ + 'id' => 'evt_test_webhook', + 'type' => 'payment_intent.canceled', + 'data' => [ + 'object' => [ + 'id' => 'pi_test_cancel_789', + 'amount' => 3000, + 'currency' => 'gbp', + 'livemode' => false, + 'cancellation_reason' => 'requested_by_customer', + ] + ] + ]; + + $this->constructWebhookEvent($payload); + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + + $this->assertNotEmpty($allEvents, 'Events should be captured by the hook'); + + $paymentEvent = $this->findEventByKey($allEvents, 'server.business_logic.payment.cancellation'); + + $this->assertNotNull($paymentEvent, 'Payment cancellation event should be found in captured events'); + + $this->assertEquals('stripe', $paymentEvent['integration'], 'Integration should be stripe'); + + $this->assertEquals('pi_test_cancel_789', $paymentEvent['id'], 'Payment intent ID should match'); + $this->assertEquals(3000, $paymentEvent['amount'], 'Amount should be 3000'); + $this->assertEquals('gbp', $paymentEvent['currency'], 'Currency should be gbp'); + $this->assertEquals(false, $paymentEvent['livemode'], 'Livemode should be false'); + $this->assertEquals('requested_by_customer', $paymentEvent['cancellation_reason'], 'Cancellation reason should match'); + }); + } + + public function testWebhookInvalidOrUnsupportedEvents() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $payload = [ + 'id' => 'evt_test_webhook', + 'type' => 'customer.created', // Not a payment_intent event + 'data' => [ + 'object' => [ + 'id' => 'cus_test_123', + 'email' => 'test@example.com', + ] + ] + ]; + + $this->constructWebhookEvent($payload); + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + + + $paymentEventKeys = [ + 'server.business_logic.payment.creation', + 'server.business_logic.payment.success', + 'server.business_logic.payment.failure', + 'server.business_logic.payment.cancellation', + ]; + $hasPaymentEvent = $this->hasEventWithAnyKey($allEvents, $paymentEventKeys); + + $this->assertFalse($hasPaymentEvent, 'Should not capture any payment events for customer.created event type'); + }); + } + + public function testPaymentSuccessWebhookViaConstructEvent() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $payload = [ + 'id' => 'evt_webhook_construct_event', + 'type' => 'payment_intent.succeeded', + 'data' => [ + 'object' => [ + 'id' => 'pi_webhook_123', + 'amount' => 1999, + 'currency' => 'eur', + 'livemode' => false, + 'payment_method' => 'pm_webhook_123', + ] + ] + ]; + $event = $this->constructWebhookEvent($payload); + + $this->assertSame('payment_intent.succeeded', $event->type); + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + $this->assertNotEmpty($allEvents); + + $paymentEvent = $this->findEventByKey($allEvents, 'server.business_logic.payment.success'); + $this->assertNotNull($paymentEvent, 'Payment success event should be found when using Webhook::constructEvent'); + $this->assertEquals('stripe', $paymentEvent['integration']); + $this->assertEquals('pi_webhook_123', $paymentEvent['id']); + $this->assertEquals(1999, $paymentEvent['amount']); + $this->assertEquals('eur', $paymentEvent['currency']); + $this->assertEquals('pm_webhook_123', $paymentEvent['payment_method']); + }); + } + + public function testCheckoutSessionCreateDirectMethod() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $sessionData = [ + 'id' => 'cs_test_direct_123', + 'object' => 'checkout.session', + 'mode' => 'payment', + 'amount_total' => 5000, + 'currency' => 'usd', + 'livemode' => false, + 'client_reference_id' => 'test_ref_direct', + 'total_details' => [ + 'amount_discount' => 0, + 'amount_shipping' => 500, + ], + 'discounts' => [ + [ + 'coupon' => 'SUMMER20', + 'promotion_code' => 'promo_123', + ] + ], + ]; + + $session = \Stripe\Checkout\Session::constructFrom($sessionData); + + \DDTrace\Integrations\Stripe\StripeIntegration::pushPaymentEvent( + 'server.business_logic.payment.creation', + \DDTrace\Integrations\Stripe\StripeIntegration::extractCheckoutSessionFields($session) + ); + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + + $this->assertNotEmpty($allEvents, 'Events should be captured'); + + $paymentEvent = $this->findEventByKey($allEvents, 'server.business_logic.payment.creation'); + + $this->assertNotNull($paymentEvent, 'Payment creation event should be found in captured events'); + + $this->assertEquals('stripe', $paymentEvent['integration'], 'Integration should be stripe'); + + $this->assertEquals('cs_test_direct_123', $paymentEvent['id'], 'Session ID should match'); + $this->assertEquals(5000, $paymentEvent['amount_total'], 'Amount total should be 5000'); + $this->assertEquals('usd', $paymentEvent['currency'], 'Currency should be usd'); + $this->assertEquals(false, $paymentEvent['livemode'], 'Livemode should be false'); + $this->assertEquals('test_ref_direct', $paymentEvent['client_reference_id'], 'Client reference ID should match'); + $this->assertEquals(0, $paymentEvent['total_details.amount_discount'], 'Discount amount should be 0'); + $this->assertEquals(500, $paymentEvent['total_details.amount_shipping'], 'Shipping amount should be 500'); + $this->assertEquals('SUMMER20', $paymentEvent['discounts.coupon'], 'Coupon should be SUMMER20'); + $this->assertEquals('promo_123', $paymentEvent['discounts.promotion_code'], 'Promotion code should be promo_123'); + }); + } + + public function testCheckoutSessionCreateDirectMethodNonPaymentMode() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $sessionData = [ + 'id' => 'cs_test_subscription_456', + 'object' => 'checkout.session', + 'mode' => 'subscription', // Not payment mode + 'amount_total' => 5000, + 'currency' => 'usd', + 'livemode' => false, + ]; + + $session = \Stripe\Checkout\Session::constructFrom($sessionData); + + if ($session->mode === 'payment') { + \DDTrace\Integrations\Stripe\StripeIntegration::pushPaymentEvent( + 'server.business_logic.payment.creation', + \DDTrace\Integrations\Stripe\StripeIntegration::extractCheckoutSessionFields($session) + ); + } + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + + + $paymentEvent = $this->findEventByKey($allEvents, 'server.business_logic.payment.creation'); + + $this->assertNull($paymentEvent, 'Payment creation event should not be captured for subscription mode'); + }); + } + + public function testPaymentIntentCreateDirectMethod() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $paymentIntentData = [ + 'id' => 'pi_test_direct_789', + 'object' => 'payment_intent', + 'amount' => 3500, + 'currency' => 'eur', + 'livemode' => false, + 'payment_method' => 'pm_test_direct_789', + 'status' => 'requires_confirmation', + ]; + + $paymentIntent = \Stripe\PaymentIntent::constructFrom($paymentIntentData); + + \DDTrace\Integrations\Stripe\StripeIntegration::pushPaymentEvent( + 'server.business_logic.payment.creation', + \DDTrace\Integrations\Stripe\StripeIntegration::extractPaymentIntentFields($paymentIntent) + ); + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + + $this->assertNotEmpty($allEvents, 'Events should be captured'); + + $paymentEvent = $this->findEventByKey($allEvents, 'server.business_logic.payment.creation'); + + $this->assertNotNull($paymentEvent, 'Payment creation event should be found in captured events'); + + $this->assertEquals('stripe', $paymentEvent['integration'], 'Integration should be stripe'); + + $this->assertEquals('pi_test_direct_789', $paymentEvent['id'], 'Payment intent ID should match'); + $this->assertEquals(3500, $paymentEvent['amount'], 'Amount should be 3500'); + $this->assertEquals('eur', $paymentEvent['currency'], 'Currency should be eur'); + $this->assertEquals(false, $paymentEvent['livemode'], 'Livemode should be false'); + $this->assertEquals('pm_test_direct_789', $paymentEvent['payment_method'], 'Payment method should match'); + }); + } + + public function testCheckoutSessionCreateViaSessionService() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $mockResponse = [ + 'id' => 'cs_test_session_service_123', + 'object' => 'checkout.session', + 'mode' => 'payment', + 'amount_total' => 4200, + 'currency' => 'eur', + 'livemode' => false, + 'client_reference_id' => 'ref_session_service', + 'total_details' => ['amount_discount' => 0, 'amount_shipping' => 200], + 'discounts' => [], + ]; + $mock = new MockStripeHttpClient(json_encode($mockResponse)); + \Stripe\ApiRequestor::setHttpClient($mock); + try { + $client = new \Stripe\StripeClient('sk_test_fake_key_for_testing'); + $session = $client->checkout->sessions->create([ + 'mode' => 'payment', + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + 'line_items' => [['price_data' => ['currency' => 'eur', 'product_data' => ['name' => 'Test'], 'unit_amount' => 4200], 'quantity' => 1]], + ]); + $this->assertSame('cs_test_session_service_123', $session->id); + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + $paymentEvent = $this->findEventByKey($allEvents, 'server.business_logic.payment.creation'); + $this->assertNotNull($paymentEvent, 'Payment creation event should be captured by SessionService::create hook'); + $this->assertEquals('stripe', $paymentEvent['integration']); + $this->assertEquals('cs_test_session_service_123', $paymentEvent['id']); + $this->assertEquals(4200, $paymentEvent['amount_total']); + $this->assertEquals('eur', $paymentEvent['currency']); + $this->assertEquals('ref_session_service', $paymentEvent['client_reference_id']); + } finally { + \Stripe\ApiRequestor::setHttpClient(\Stripe\HttpClient\CurlClient::instance()); + } + }); + } + + public function testCheckoutSessionCreateViaStaticMethod() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $mockResponse = [ + 'id' => 'cs_test_static_456', + 'object' => 'checkout.session', + 'mode' => 'payment', + 'amount_total' => 1000, + 'currency' => 'usd', + 'livemode' => false, + 'client_reference_id' => 'ref_static', + 'total_details' => ['amount_discount' => 0, 'amount_shipping' => 0], + 'discounts' => [], + ]; + $mock = new MockStripeHttpClient(json_encode($mockResponse)); + \Stripe\ApiRequestor::setHttpClient($mock); + try { + $session = \Stripe\Checkout\Session::create([ + 'mode' => 'payment', + 'success_url' => 'https://example.com/success', + 'cancel_url' => 'https://example.com/cancel', + 'line_items' => [['price_data' => ['currency' => 'usd', 'product_data' => ['name' => 'Item'], 'unit_amount' => 1000], 'quantity' => 1]], + ]); + $this->assertSame('cs_test_static_456', $session->id); + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + $paymentEvent = $this->findEventByKey($allEvents, 'server.business_logic.payment.creation'); + $this->assertNotNull($paymentEvent, 'Payment creation event should be captured by Checkout\Session::create hook'); + $this->assertEquals('stripe', $paymentEvent['integration']); + $this->assertEquals('cs_test_static_456', $paymentEvent['id']); + $this->assertEquals(1000, $paymentEvent['amount_total']); + $this->assertEquals('usd', $paymentEvent['currency']); + } finally { + \Stripe\ApiRequestor::setHttpClient(\Stripe\HttpClient\CurlClient::instance()); + } + }); + } + + public function testPaymentIntentCreateViaPaymentIntentService() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $mockResponse = [ + 'id' => 'pi_test_service_789', + 'object' => 'payment_intent', + 'amount' => 5000, + 'currency' => 'gbp', + 'livemode' => false, + 'payment_method' => 'pm_test_service_789', + 'status' => 'requires_payment_method', + ]; + $mock = new MockStripeHttpClient(json_encode($mockResponse)); + \Stripe\ApiRequestor::setHttpClient($mock); + try { + $client = new \Stripe\StripeClient('sk_test_fake_key_for_testing'); + $paymentIntent = $client->paymentIntents->create([ + 'amount' => 5000, + 'currency' => 'gbp', + 'payment_method_types' => ['card'], + ]); + $this->assertSame('pi_test_service_789', $paymentIntent->id); + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + $paymentEvent = $this->findEventByKey($allEvents, 'server.business_logic.payment.creation'); + $this->assertNotNull($paymentEvent, 'Payment creation event should be captured by PaymentIntentService::create hook'); + $this->assertEquals('stripe', $paymentEvent['integration']); + $this->assertEquals('pi_test_service_789', $paymentEvent['id']); + $this->assertEquals(5000, $paymentEvent['amount']); + $this->assertEquals('gbp', $paymentEvent['currency']); + $this->assertEquals('pm_test_service_789', $paymentEvent['payment_method']); + } finally { + \Stripe\ApiRequestor::setHttpClient(\Stripe\HttpClient\CurlClient::instance()); + } + }); + } + + public function testPaymentIntentCreateViaStaticMethod() + { + $this->isolateTracer(function () { + \Stripe\Stripe::setApiKey('sk_test_fake_key_for_testing'); + + $mockResponse = [ + 'id' => 'pi_test_static_999', + 'object' => 'payment_intent', + 'amount' => 7500, + 'currency' => 'jpy', + 'livemode' => false, + 'payment_method' => 'pm_test_static_999', + 'status' => 'requires_confirmation', + ]; + $mock = new MockStripeHttpClient(json_encode($mockResponse)); + \Stripe\ApiRequestor::setHttpClient($mock); + try { + $paymentIntent = \Stripe\PaymentIntent::create([ + 'amount' => 7500, + 'currency' => 'jpy', + 'payment_method_types' => ['card'], + ]); + $this->assertSame('pi_test_static_999', $paymentIntent->id); + + $allEvents = AppsecStatus::getInstance()->getEvents(['push_addresses'], []); + $paymentEvent = $this->findEventByKey($allEvents, 'server.business_logic.payment.creation'); + $this->assertNotNull($paymentEvent, 'Payment creation event should be captured by PaymentIntent::create hook'); + $this->assertEquals('stripe', $paymentEvent['integration']); + $this->assertEquals('pi_test_static_999', $paymentEvent['id']); + $this->assertEquals(7500, $paymentEvent['amount']); + $this->assertEquals('jpy', $paymentEvent['currency']); + $this->assertEquals('pm_test_static_999', $paymentEvent['payment_method']); + } finally { + \Stripe\ApiRequestor::setHttpClient(\Stripe\HttpClient\CurlClient::instance()); + } + }); + } +} + +/** + * Mock HTTP client for Stripe API requests. Returns a fixed JSON response so hooks can run without a real server. + */ +class MockStripeHttpClient implements \Stripe\HttpClient\ClientInterface +{ + /** @var string */ + private $responseBody; + /** @var int */ + private $responseCode; + + public function __construct(string $responseBody, int $responseCode = 200) + { + $this->responseBody = $responseBody; + $this->responseCode = $responseCode; + } + + public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = null, $maxNetworkRetries = null) + { + return [$this->responseBody, $this->responseCode, []]; + } +} diff --git a/tests/Integrations/Stripe/Latest/composer.json b/tests/Integrations/Stripe/Latest/composer.json new file mode 100644 index 00000000000..653cfa44a86 --- /dev/null +++ b/tests/Integrations/Stripe/Latest/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "stripe/stripe-php": "^13.0" + }, + "autoload-dev": { + "files": ["../../../../tests/Appsec/Mock.php"] + } +}