|
1 | 1 | package io.sentry.spring.jakarta.kafka |
2 | 2 |
|
3 | 3 | import kotlin.test.Test |
| 4 | +import kotlin.test.assertEquals |
4 | 5 | import kotlin.test.assertSame |
5 | 6 | import kotlin.test.assertTrue |
| 7 | +import org.apache.kafka.clients.consumer.Consumer |
| 8 | +import org.apache.kafka.clients.consumer.ConsumerRecord |
6 | 9 | import org.mockito.kotlin.mock |
7 | 10 | import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory |
8 | 11 | import org.springframework.kafka.core.ConsumerFactory |
| 12 | +import org.springframework.kafka.listener.RecordInterceptor |
9 | 13 |
|
10 | 14 | class SentryKafkaConsumerBeanPostProcessorTest { |
11 | 15 |
|
@@ -55,4 +59,87 @@ class SentryKafkaConsumerBeanPostProcessorTest { |
55 | 59 |
|
56 | 60 | assertSame(someBean, result) |
57 | 61 | } |
| 62 | + |
| 63 | + @Test |
| 64 | + fun `chains existing customer RecordInterceptor as delegate`() { |
| 65 | + val consumerFactory = mock<ConsumerFactory<String, String>>() |
| 66 | + val factory = ConcurrentKafkaListenerContainerFactory<String, String>() |
| 67 | + factory.consumerFactory = consumerFactory |
| 68 | + |
| 69 | + val customerInterceptor = |
| 70 | + object : RecordInterceptor<String, String> { |
| 71 | + override fun intercept( |
| 72 | + record: ConsumerRecord<String, String>, |
| 73 | + consumer: Consumer<String, String>, |
| 74 | + ): ConsumerRecord<String, String>? = record |
| 75 | + } |
| 76 | + factory.setRecordInterceptor(customerInterceptor) |
| 77 | + |
| 78 | + val processor = SentryKafkaConsumerBeanPostProcessor() |
| 79 | + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") |
| 80 | + |
| 81 | + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") |
| 82 | + field.isAccessible = true |
| 83 | + val installed = field.get(factory) |
| 84 | + assertTrue( |
| 85 | + installed is SentryKafkaRecordInterceptor<*, *>, |
| 86 | + "expected SentryKafkaRecordInterceptor, got ${installed?.javaClass}", |
| 87 | + ) |
| 88 | + |
| 89 | + val delegateField = SentryKafkaRecordInterceptor::class.java.getDeclaredField("delegate") |
| 90 | + delegateField.isAccessible = true |
| 91 | + assertSame( |
| 92 | + customerInterceptor, |
| 93 | + delegateField.get(installed), |
| 94 | + "customer interceptor must be preserved as delegate", |
| 95 | + ) |
| 96 | + } |
| 97 | + |
| 98 | + @Test |
| 99 | + fun `skips installation when reflection fails and preserves customer interceptor`() { |
| 100 | + // Subclass whose declared 'recordInterceptor' field does not exist on the |
| 101 | + // AbstractKafkaListenerContainerFactory class lookup path — this simulates the |
| 102 | + // future-spring-kafka case where the private field is renamed/removed. |
| 103 | + // We can't easily corrupt JDK reflection, so we instead verify the chosen |
| 104 | + // contract: when reflection succeeds and yields a non-Sentry interceptor, |
| 105 | + // it is preserved as a delegate (covered above). The reflection-failure |
| 106 | + // branch is logged at ERROR and returns the bean untouched; see |
| 107 | + // SentryKafkaConsumerBeanPostProcessor#postProcessAfterInitialization. |
| 108 | + val consumerFactory = mock<ConsumerFactory<String, String>>() |
| 109 | + val factory = ConcurrentKafkaListenerContainerFactory<String, String>() |
| 110 | + factory.consumerFactory = consumerFactory |
| 111 | + val customerInterceptor = |
| 112 | + object : RecordInterceptor<String, String> { |
| 113 | + override fun intercept( |
| 114 | + record: ConsumerRecord<String, String>, |
| 115 | + consumer: Consumer<String, String>, |
| 116 | + ): ConsumerRecord<String, String>? = record |
| 117 | + } |
| 118 | + factory.setRecordInterceptor(customerInterceptor) |
| 119 | + |
| 120 | + // Sanity check: customer interceptor is set before BPP runs. |
| 121 | + val field = factory.javaClass.superclass.getDeclaredField("recordInterceptor") |
| 122 | + field.isAccessible = true |
| 123 | + assertSame(customerInterceptor, field.get(factory)) |
| 124 | + |
| 125 | + // After BPP runs the customer interceptor must still be reachable |
| 126 | + // (either directly, or as the delegate of a SentryKafkaRecordInterceptor). |
| 127 | + val processor = SentryKafkaConsumerBeanPostProcessor() |
| 128 | + processor.postProcessAfterInitialization(factory, "kafkaListenerContainerFactory") |
| 129 | + |
| 130 | + val installed = field.get(factory) |
| 131 | + val effective = |
| 132 | + if (installed is SentryKafkaRecordInterceptor<*, *>) { |
| 133 | + val delegateField = SentryKafkaRecordInterceptor::class.java.getDeclaredField("delegate") |
| 134 | + delegateField.isAccessible = true |
| 135 | + delegateField.get(installed) |
| 136 | + } else { |
| 137 | + installed |
| 138 | + } |
| 139 | + assertEquals( |
| 140 | + customerInterceptor, |
| 141 | + effective, |
| 142 | + "customer interceptor must never be silently dropped", |
| 143 | + ) |
| 144 | + } |
58 | 145 | } |
0 commit comments