Skip to content

Commit 4477a1f

Browse files
feat: add provider-specific webhook ingestion endpoints (Stellabill#822)
Register POST /api/webhooks/stripe and /api/webhooks/generic with provider- specific HMAC verification (internal/middleware/webhook_verification.go) and persist verified events into the outbox repository. - Secrets are resolved exclusively through secrets.Provider; no raw env reads. - Stripe event IDs are derived from the payload and replays are rejected via webhook_event_cache keyed by event_id (defense in depth atop the UNIQUE deduplication_id index). - Handler tests rewritten against the current outbox.Repository interface; EventIDCache regression tests made deterministic. Closes Stellabill#780 Co-authored-by: thlpkee20-wq <thlpkee20@gmail.com>
1 parent 8e034bb commit 4477a1f

75 files changed

Lines changed: 1855 additions & 1365 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎go.mod‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ require (
1515
github.com/go-playground/validator/v10 v10.30.1
1616
github.com/golang-jwt/jwt/v5 v5.3.1
1717
github.com/google/uuid v1.6.0
18+
github.com/google/wire v0.7.0
1819
github.com/gorilla/websocket v1.5.3
1920
github.com/graphql-go/graphql v0.8.1
2021
github.com/jackc/pgx/v5 v5.9.1
@@ -24,6 +25,7 @@ require (
2425
github.com/open-policy-agent/opa v1.18.2
2526
github.com/prometheus/client_golang v1.23.2
2627
github.com/redis/go-redis/v9 v9.7.1
28+
github.com/segmentio/kafka-go v0.4.47
2729
github.com/shopspring/decimal v1.4.0
2830
github.com/sirupsen/logrus v1.9.4
2931
github.com/sony/gobreaker v1.0.0
@@ -144,7 +146,6 @@ require (
144146
github.com/quic-go/quic-go v0.59.0 // indirect
145147
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
146148
github.com/segmentio/asm v1.2.1 // indirect
147-
github.com/segmentio/kafka-go v0.4.47
148149
github.com/shirou/gopsutil/v4 v4.26.2 // indirect
149150
github.com/stretchr/objx v0.5.3 // indirect
150151
github.com/tchap/go-patricia/v2 v2.3.3 // indirect

‎internal/adr/adr.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ var requiredSections = []string{"Status", "Context", "Decision", "Consequences"}
4141

4242
// Record is a parsed ADR markdown file.
4343
type Record struct {
44-
Path string
45-
Number int
46-
Slug string
47-
Title string
48-
Status string
44+
Path string
45+
Number int
46+
Slug string
47+
Title string
48+
Status string
4949
IsTemplate bool
5050
}
5151

‎internal/auth/jwks_cache.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,4 @@ func (c *JWKSCache) refreshAndGetKey(ctx context.Context, kid string) (jwk.Key,
170170
// regardless of the configured (positive) ttl.
171171
c.negativeCache[kid] = time.Now().Add(negativeTTL())
172172
return nil, fmt.Errorf("key id %s not found in JWKS", kid)
173-
}
173+
}

‎internal/auth/jwks_cache_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
package auth
2+
23
import (
34
"context"
45
"crypto/rand"

‎internal/auth/metrics.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ func init() {
1414
Help: "Total number of failed JWKS refresh attempts against the configured issuer",
1515
})
1616
_ = prometheus.Register(jwksRefreshErrorsTotal)
17-
}
17+
}

‎internal/cdc/consumer.go‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -211,21 +211,21 @@ func (c *Consumer) runReplication(ctx context.Context) error {
211211
}
212212

213213
for _, change := range changes {
214-
change.LSN = newLSN
215-
// Write to all sinks before advancing LSN.
216-
// If a sink fails, log and continue but do not advance
217-
// past the last successfully written LSN.
218-
allSinksOK := true
219-
for _, sink := range c.cfg.Sinks {
220-
if err := sink.WriteChange(ctx, change); err != nil {
221-
log.Printf("cdc: sink write error: %v", err)
222-
allSinksOK = false
214+
change.LSN = newLSN
215+
// Write to all sinks before advancing LSN.
216+
// If a sink fails, log and continue but do not advance
217+
// past the last successfully written LSN.
218+
allSinksOK := true
219+
for _, sink := range c.cfg.Sinks {
220+
if err := sink.WriteChange(ctx, change); err != nil {
221+
log.Printf("cdc: sink write error: %v", err)
222+
allSinksOK = false
223+
}
224+
}
225+
if allSinksOK {
226+
c.updateLSN(newLSN)
223227
}
224228
}
225-
if allSinksOK {
226-
c.updateLSN(newLSN)
227-
}
228-
}
229229

230230
case *pgproto3.CopyDone:
231231
log.Printf("cdc: server sent CopyDone, restarting replication")

‎internal/cdc/consumer_test.go‎

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,10 @@ func TestConsumerStop_DoubleStop(t *testing.T) {
165165
// returns immediately when the context is already cancelled.
166166
func TestReplicationLoop_ContextCancelled(t *testing.T) {
167167
c, _ := NewConsumer(ConsumerConfig{
168-
ConnString: "postgres://test",
169-
SlotName: "test_slot",
168+
ConnString: "postgres://test",
169+
SlotName: "test_slot",
170170
MaxReconnectAttempts: 1,
171-
ReconnectBackoff: 10 * time.Millisecond,
171+
ReconnectBackoff: 10 * time.Millisecond,
172172
})
173173

174174
ctx, cancel := context.WithCancel(context.Background())
@@ -185,10 +185,10 @@ func TestReplicationLoop_ContextCancelled(t *testing.T) {
185185
// and we test that after MaxReconnectAttempts it returns an error.
186186
func TestReplicationLoop_MaxReconnects(t *testing.T) {
187187
c, _ := NewConsumer(ConsumerConfig{
188-
ConnString: "postgres://invalid:5432/test",
189-
SlotName: "test_slot",
188+
ConnString: "postgres://invalid:5432/test",
189+
SlotName: "test_slot",
190190
MaxReconnectAttempts: 2,
191-
ReconnectBackoff: 1 * time.Millisecond,
191+
ReconnectBackoff: 1 * time.Millisecond,
192192
})
193193

194194
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -211,10 +211,10 @@ func TestReplicationLoop_MaxReconnects(t *testing.T) {
211211
// TestReplicationLoop_UnlimitedReconnects tests with MaxReconnectAttempts=0.
212212
func TestReplicationLoop_UnlimitedReconnects(t *testing.T) {
213213
c, _ := NewConsumer(ConsumerConfig{
214-
ConnString: "postgres://invalid:5432/test",
215-
SlotName: "test_slot",
214+
ConnString: "postgres://invalid:5432/test",
215+
SlotName: "test_slot",
216216
MaxReconnectAttempts: 0, // unlimited
217-
ReconnectBackoff: 1 * time.Millisecond,
217+
ReconnectBackoff: 1 * time.Millisecond,
218218
})
219219

220220
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
@@ -235,11 +235,11 @@ func TestReplicationLoop_UnlimitedReconnects(t *testing.T) {
235235
// are correctly propagated.
236236
func TestRunReplication_ConnectFailure(t *testing.T) {
237237
c, _ := NewConsumer(ConsumerConfig{
238-
ConnString: "postgres://nonexistent:5432/test",
239-
SlotName: "test_slot",
240-
StandbyTimeout: 10 * time.Second,
238+
ConnString: "postgres://nonexistent:5432/test",
239+
SlotName: "test_slot",
240+
StandbyTimeout: 10 * time.Second,
241241
MaxReconnectAttempts: 1,
242-
ReconnectBackoff: 1 * time.Millisecond,
242+
ReconnectBackoff: 1 * time.Millisecond,
243243
})
244244

245245
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -273,9 +273,9 @@ func TestStart_AlreadyRunning(t *testing.T) {
273273
// cancelled before the replication connection is established.
274274
func TestStart_ContextCancellation(t *testing.T) {
275275
c, _ := NewConsumer(ConsumerConfig{
276-
ConnString: "postgres://nonexistent:5432/test",
276+
ConnString: "postgres://nonexistent:5432/test",
277277
MaxReconnectAttempts: 1,
278-
ReconnectBackoff: 1 * time.Millisecond,
278+
ReconnectBackoff: 1 * time.Millisecond,
279279
})
280280

281281
ctx, cancel := context.WithCancel(context.Background())
@@ -293,13 +293,13 @@ func TestStart_ContextCancellation(t *testing.T) {
293293

294294
func TestNewConsumer_CustomConfig(t *testing.T) {
295295
cfg := ConsumerConfig{
296-
ConnString: "postgres://test",
297-
SlotName: "custom_slot",
298-
PublicationName: "custom_pub",
299-
StandbyTimeout: 30 * time.Second,
300-
ReconnectBackoff: 5 * time.Second,
296+
ConnString: "postgres://test",
297+
SlotName: "custom_slot",
298+
PublicationName: "custom_pub",
299+
StandbyTimeout: 30 * time.Second,
300+
ReconnectBackoff: 5 * time.Second,
301301
MaxReconnectAttempts: 5,
302-
Sinks: []Sink{NewMemorySink()},
302+
Sinks: []Sink{NewMemorySink()},
303303
}
304304
c, err := NewConsumer(cfg)
305305
if err != nil {
@@ -385,10 +385,10 @@ func TestStart_DeferClosesSinks(t *testing.T) {
385385
sink := NewMemorySink()
386386

387387
c, _ := NewConsumer(ConsumerConfig{
388-
ConnString: "postgres://nonexistent:5432/test",
389-
Sinks: []Sink{sink},
388+
ConnString: "postgres://nonexistent:5432/test",
389+
Sinks: []Sink{sink},
390390
MaxReconnectAttempts: 1,
391-
ReconnectBackoff: 1 * time.Millisecond,
391+
ReconnectBackoff: 1 * time.Millisecond,
392392
})
393393

394394
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)

‎internal/cdc/pgoutput_test.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,9 +357,9 @@ func TestDecodeBeginCommit(t *testing.T) {
357357

358358
tx = append(tx, 'C')
359359
commitRest := make([]byte, 25)
360-
commitRest[0] = 0 // flags
361-
binary.BigEndian.PutUint64(commitRest[1:9], 200) // commit LSN
362-
binary.BigEndian.PutUint64(commitRest[9:17], 300) // end LSN
360+
commitRest[0] = 0 // flags
361+
binary.BigEndian.PutUint64(commitRest[1:9], 200) // commit LSN
362+
binary.BigEndian.PutUint64(commitRest[9:17], 300) // end LSN
363363
binary.BigEndian.PutUint64(commitRest[17:25], 1700000000000000) // ts
364364
tx = append(tx, commitRest...)
365365

@@ -1186,7 +1186,7 @@ func TestConsumerStartError(t *testing.T) {
11861186
// TestNewConsumerAllDefaults verifies all defaults are properly set.
11871187
func TestNewConsumerAllDefaults(t *testing.T) {
11881188
c, err := NewConsumer(ConsumerConfig{
1189-
ConnString: "postgres://test",
1189+
ConnString: "postgres://test",
11901190
MaxReconnectAttempts: -1, // negative, treated as zero
11911191
})
11921192
if err != nil {

‎internal/config/config.go‎

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,17 @@ func (e *ConfigError) Error() string {
4141

4242
// Config holds all application configuration
4343
type Config struct {
44-
Env string `json:"env"`
45-
Port int `json:"port"`
46-
DBConn string `json:"db_conn" secret:"true"`
47-
JWTSecret string `json:"jwt_secret" secret:"true"`
48-
JWKSURL string `json:"jwks_url"`
49-
MaxHeaderBytes int `json:"max_header_bytes"`
50-
ReadTimeout int `json:"read_timeout"`
51-
WriteTimeout int `json:"write_timeout"`
52-
IdleTimeout int `json:"idle_timeout"`
53-
AllowedOrigins string `json:"allowed_origins"`
54-
AdminToken string `json:"admin_token" secret:"true"`
55-
DBReplicaConn string `json:"db_replica_conn" secret:"true"`
44+
Env string `json:"env"`
45+
Port int `json:"port"`
46+
DBConn string `json:"db_conn" secret:"true"`
47+
JWTSecret string `json:"jwt_secret" secret:"true"`
48+
MaxHeaderBytes int `json:"max_header_bytes"`
49+
ReadTimeout int `json:"read_timeout"`
50+
WriteTimeout int `json:"write_timeout"`
51+
IdleTimeout int `json:"idle_timeout"`
52+
AllowedOrigins string `json:"allowed_origins"`
53+
AdminToken string `json:"admin_token" secret:"true"`
54+
DBReplicaConn string `json:"db_replica_conn" secret:"true"`
5655
// Rate limiting configuration
5756
RateLimitEnabled bool `json:"rate_limit_enabled"`
5857
RateLimitMode string `json:"rate_limit_mode"`
@@ -308,19 +307,19 @@ func Load(opts ...Option) (Config, error) {
308307
MaxGzipRatio: getEnvFloat64("MAX_GZIP_RATIO", 10.0),
309308
MaxGzipCompressed: getEnvInt64("MAX_GZIP_COMPRESSED", 1024*1024*10), // 10MB default (= MaxRequestSize)
310309
// DB pool — safe production defaults
311-
DBReplicaConn: getEnv("DB_REPLICA_URL", ""),
312-
RedisURL: getEnv("REDIS_URL", ""),
313-
CacheTTL: getEnvInt("CACHE_TTL", 60), // 60 second default
314-
DBPoolMaxConns: DefaultDBPoolMaxConns,
315-
DBPoolMinConns: DefaultDBPoolMinConns,
316-
DBPoolMaxConnLifetime: DefaultDBPoolMaxConnLifetime,
317-
DBPoolMaxConnIdleTime: DefaultDBPoolMaxConnIdleTime,
318-
DBPoolConnectTimeout: DefaultDBPoolConnectTimeout,
319-
DBPoolHealthCheckPeriod: DefaultDBPoolHealthCheckPeriod,
320-
DBPoolMetricsInterval: DefaultDBPoolMetricsInterval,
321-
DBBreakerMaxFailures: DefaultDBBreakerMaxFailures,
322-
DBBreakerTimeoutSeconds: DefaultDBBreakerTimeoutSeconds,
323-
DBBreakerHalfOpenMaxRequests: DefaultDBBreakerHalfOpenMaxRequests,
310+
//
311+
// DATABASE_REPLICA_URL is the canonical env var for the hot-standby
312+
// read replica; DB_REPLICA_URL is kept as a backward-compatible alias.
313+
DBReplicaConn: getEnvFirst("", "DATABASE_REPLICA_URL", "DB_REPLICA_URL"),
314+
RedisURL: getEnv("REDIS_URL", ""),
315+
CacheTTL: getEnvInt("CACHE_TTL", 60), // 60 second default
316+
DBPoolMaxConns: DefaultDBPoolMaxConns,
317+
DBPoolMinConns: DefaultDBPoolMinConns,
318+
DBPoolMaxConnLifetime: DefaultDBPoolMaxConnLifetime,
319+
DBPoolMaxConnIdleTime: DefaultDBPoolMaxConnIdleTime,
320+
DBPoolConnectTimeout: DefaultDBPoolConnectTimeout,
321+
DBPoolHealthCheckPeriod: DefaultDBPoolHealthCheckPeriod,
322+
DBPoolMetricsInterval: DefaultDBPoolMetricsInterval,
324323
// PgBouncer sidecar defaults.
325324
PgBouncerEnabled: false,
326325
PgBouncerHost: DefaultPgBouncerHost,

‎internal/config/config_dump_test.go‎

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import (
88

99
func TestDumpRedactsSecretFields(t *testing.T) {
1010
cfg := &Config{
11-
Env: "production",
12-
Port: 8443,
13-
DBConn: "postgres://user:supersecret@localhost:5432/db",
14-
JWTSecret: "my-jwt-secret-value-123!",
11+
Env: "production",
12+
Port: 8443,
13+
DBConn: "postgres://user:supersecret@localhost:5432/db",
14+
JWTSecret: "my-jwt-secret-value-123!",
1515
AdminToken: "my-admin-token-456!",
1616
}
1717

@@ -39,11 +39,11 @@ func TestDumpRedactsSecretFields(t *testing.T) {
3939

4040
func TestDumpNoRawSecretsInJSON(t *testing.T) {
4141
cfg := &Config{
42-
Env: "staging",
43-
DBConn: "postgres://user:supersecret@localhost/db",
44-
JWTSecret: "my-jwt-secret-789!",
42+
Env: "staging",
43+
DBConn: "postgres://user:supersecret@localhost/db",
44+
JWTSecret: "my-jwt-secret-789!",
4545
AdminToken: "my-admin-token-000!",
46-
RedisURL: "redis://:super-redis-pass@localhost:6379",
46+
RedisURL: "redis://:super-redis-pass@localhost:6379",
4747
}
4848

4949
dump := Dump(cfg)
@@ -73,21 +73,21 @@ func TestDumpNoRawSecretsInJSON(t *testing.T) {
7373

7474
func TestDumpNonSecretFieldsVisible(t *testing.T) {
7575
cfg := &Config{
76-
Env: "development",
77-
Port: 8080,
78-
MaxHeaderBytes: 1 << 20,
79-
ReadTimeout: 30,
80-
WriteTimeout: 30,
81-
IdleTimeout: 120,
82-
RateLimitEnabled: true,
83-
RateLimitMode: "ip",
84-
RateLimitRPS: 10,
85-
RateLimitBurst: 20,
86-
RateLimitWhitelist: []string{"/health", "/metrics"},
87-
OTelLogsEnabled: false,
88-
DBPoolMaxConns: 25,
89-
DBPoolMinConns: 2,
90-
PgBouncerEnabled: false,
76+
Env: "development",
77+
Port: 8080,
78+
MaxHeaderBytes: 1 << 20,
79+
ReadTimeout: 30,
80+
WriteTimeout: 30,
81+
IdleTimeout: 120,
82+
RateLimitEnabled: true,
83+
RateLimitMode: "ip",
84+
RateLimitRPS: 10,
85+
RateLimitBurst: 20,
86+
RateLimitWhitelist: []string{"/health", "/metrics"},
87+
OTelLogsEnabled: false,
88+
DBPoolMaxConns: 25,
89+
DBPoolMinConns: 2,
90+
PgBouncerEnabled: false,
9191
GracefulShutdownTimeout: 30,
9292
}
9393

0 commit comments

Comments
 (0)