package delivery import ( "context" "log/slog" "net" "net/http" "net/netip" "net/url" "time" "github.com/prometheus/client_golang/prometheus" "go.uber.org/fx" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/metrics" ) // Exported constants for test access. const ( ExportDeliveryChannelSize = deliveryChannelSize ExportRetryChannelSize = retryChannelSize ExportDefaultFailureThreshold = defaultFailureThreshold ExportDefaultCooldown = defaultCooldown // ExportMaxBodyLog is the cap the engine applies to a // recorded response body. The event log's handling of a cut // response is written against this number, so a test has to // be able to name it. ExportMaxBodyLog = maxBodyLog // ExportPendingSweepMinAge is how long a delivery must sit at // pending before the sweep treats it as stranded. A test has to // name it to age a row past the bound. ExportPendingSweepMinAge = pendingSweepMinAge ) // NewTestGuard builds an SSRF Guard from an explicit egress // allowlist, without going through config. Passing no prefixes // yields the default guard, which blocks every private/reserved // range. func NewTestGuard(allowed ...netip.Prefix) *Guard { return &Guard{allowed: allowed} } // ExportCheckIP exposes the guard's single decision point, so a // test can assert the policy both the validator and the dialer // inherit without needing a live destination. func (g *Guard) ExportCheckIP(ip net.IP) error { return g.checkIP(ip) } // ExportAlwaysBlockedNetworks exposes alwaysBlockedNetworks. func ExportAlwaysBlockedNetworks() []*net.IPNet { return alwaysBlockedNetworks } // ExportBlockedNetworks exposes blockedNetworks. func ExportBlockedNetworks() []*net.IPNet { return blockedNetworks } // ExportBlockedPublicNetworks exposes blockedPublicNetworks. func ExportBlockedPublicNetworks() []*net.IPNet { return blockedPublicNetworks } // ExportIsForwardableHeader exposes isForwardableHeader. func ExportIsForwardableHeader(name string) bool { return isForwardableHeader(name) } // ExportApplyRequestHeaders exposes applyRequestHeaders, so a test // can inspect the header set an outbound delivery actually carries // and the origin-scoped names it reports for the redirect policy. func ExportApplyRequestHeaders( req *http.Request, event *database.Event, cfg *HTTPTargetConfig, userAgent string, ) []string { return applyRequestHeaders(req, event, cfg, userAgent) } // ExportTruncate exposes truncate for testing. func ExportTruncate(s string, maxLen int) string { return truncate(s, maxLen) } // ExportDeliverHTTP delivers via the http target for testing. func (e *Engine) ExportDeliverHTTP( ctx context.Context, webhookDB *gorm.DB, d *database.Delivery, task *Task, ) { e.httpTarget.Deliver(ctx, webhookDB, d, task, e) } // ExportDeliverHTTPWithScheduler delivers via the http target, handing // any retry to sched instead of the engine, so a test can see the // delay each retry is given. func (e *Engine) ExportDeliverHTTPWithScheduler( ctx context.Context, webhookDB *gorm.DB, d *database.Delivery, task *Task, sched Scheduler, ) { e.httpTarget.Deliver(ctx, webhookDB, d, task, sched) } // ExportDeliverDatabase delivers via the database target. func (e *Engine) ExportDeliverDatabase( webhookDB *gorm.DB, d *database.Delivery, ) { e.targets[database.TargetTypeDatabase].Deliver( context.Background(), webhookDB, d, &Task{}, e, ) } // ExportDeliverLog delivers via the log target for testing. func (e *Engine) ExportDeliverLog( webhookDB *gorm.DB, d *database.Delivery, ) { e.targets[database.TargetTypeLog].Deliver( context.Background(), webhookDB, d, &Task{}, e, ) } // ExportDeliverSlack delivers via the slack target for // testing. func (e *Engine) ExportDeliverSlack( ctx context.Context, webhookDB *gorm.DB, d *database.Delivery, ) { task := &Task{ DeliveryID: d.ID, TargetID: d.TargetID, MaxAttempts: d.Target.MaxAttempts, AttemptNum: 1, } e.targets[database.TargetTypeSlack].Deliver( ctx, webhookDB, d, task, e, ) } // ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no // target type, so no metric moves. func (e *Engine) ExportUpdateDeliveryStatus( webhookDB *gorm.DB, d *database.Delivery, status database.DeliveryStatus, ) error { return e.updateDeliveryStatus(webhookDB, d, "", status) } // ExportProcessNewTask exposes processNewTask. func (e *Engine) ExportProcessNewTask( ctx context.Context, task *Task, ) { e.processNewTask(ctx, task) } // ExportProcessRetryTask exposes processRetryTask. func (e *Engine) ExportProcessRetryTask( ctx context.Context, task *Task, ) { e.processRetryTask(ctx, task) } // ExportEventForTask exposes the event reconstruction the delivery // paths run: buildEventFromTask followed by hydrateEvent. func (e *Engine) ExportEventForTask( webhookDB *gorm.DB, task *Task, ) (database.Event, error) { return e.hydrateEvent( webhookDB, buildEventFromTask(task), task, ) } // ExportProcessDelivery exposes processDelivery. func (e *Engine) ExportProcessDelivery( ctx context.Context, webhookDB *gorm.DB, d *database.Delivery, task *Task, ) { e.processDelivery(ctx, webhookDB, d, task) } // ExportGetCircuitBreaker exposes the http target's // getCircuitBreaker. func (e *Engine) ExportGetCircuitBreaker( targetID string, ) *CircuitBreaker { return e.httpTarget.getCircuitBreaker(targetID) } // ExportSetCircuitBreaker makes cb the http target's circuit breaker // for targetID, so a test can use one with a short cooldown. func (e *Engine) ExportSetCircuitBreaker( targetID string, cb *CircuitBreaker, ) { e.httpTarget.circuitBreakers.Store(targetID, cb) } // ExportSetSlackCircuitBreaker is ExportSetCircuitBreaker for the // slack target. func (e *Engine) ExportSetSlackCircuitBreaker( targetID string, cb *CircuitBreaker, ) { e.slackTarget.circuitBreakers.Store(targetID, cb) } // ExportParseHTTPConfig exposes parseHTTPConfig. func (e *Engine) ExportParseHTTPConfig( configJSON string, ) (*HTTPTargetConfig, error) { return parseHTTPConfig(configJSON) } // ExportParseSlackConfig exposes parseSlackConfig. func (e *Engine) ExportParseSlackConfig( configJSON string, ) (*SlackTargetConfig, error) { return parseSlackConfig(configJSON) } // ExportDoHTTPRequest exposes the http target's // doHTTPRequest. func (e *Engine) ExportDoHTTPRequest( ctx context.Context, cfg *HTTPTargetConfig, event *database.Event, ) (int, string, int64, error) { return e.httpTarget.doHTTPRequest(ctx, cfg, event) } // ExportClientForRequest exposes the http target's // clientForRequest. func (e *Engine) ExportClientForRequest( cfg *HTTPTargetConfig, originScoped []string, ) *http.Client { return e.httpTarget.clientForRequest(cfg, originScoped) } // ErrExportTooManyRedirects exposes the sentinel the redirect // policy returns once a chain exceeds the hop cap. It carries the // Err prefix rather than this file's usual Export one because it // is a sentinel error. var ErrExportTooManyRedirects = errTooManyRedirects // ExportMaxDeliveryRedirects exposes the redirect hop cap. const ExportMaxDeliveryRedirects = maxDeliveryRedirects // ExportSameDeliveryOrigin exposes sameDeliveryOrigin. func ExportSameDeliveryOrigin(origin, dest *url.URL) bool { return sameDeliveryOrigin(origin, dest) } // ExportClient returns the http target's shared HTTP client. func (e *Engine) ExportClient() *http.Client { return e.httpTarget.client } // ExportScheduleRetry exposes ScheduleRetry. func (e *Engine) ExportScheduleRetry( task Task, delay time.Duration, ) { e.ScheduleRetry(task, delay) } // ExportRecoverPendingDeliveries exposes // recoverPendingDeliveries. func (e *Engine) ExportRecoverPendingDeliveries( ctx context.Context, webhookDB *gorm.DB, webhookID string, ) { e.recoverPendingDeliveries( ctx, webhookDB, webhookID, ) } // ExportRecoverWebhookDeliveries exposes // recoverWebhookDeliveries. func (e *Engine) ExportRecoverWebhookDeliveries( ctx context.Context, webhookID string, ) { e.recoverWebhookDeliveries(ctx, webhookID) } // ExportRecoverInFlight exposes recoverInFlight. func (e *Engine) ExportRecoverInFlight( ctx context.Context, ) { e.recoverInFlight(ctx) } // ExportSweepWebhookRetries exposes sweepWebhookRetries. func (e *Engine) ExportSweepWebhookRetries( ctx context.Context, webhookID string, ) { e.sweepWebhookRetries(ctx, webhookID) } // ExportStart exposes start for testing. func (e *Engine) ExportStart() { e.start() } // ExportRegisterHooks registers the engine's real fx lifecycle // hooks on a lifecycle supplied by a test, so a test can drive // the exact OnStart/OnStop functions the application runs and // hand OnStart the kind of context fx actually supplies. func (e *Engine) ExportRegisterHooks(lc fx.Lifecycle) { e.registerHooks(lc) } // ExportStop exposes stop for testing. func (e *Engine) ExportStop(ctx context.Context) error { return e.stop(ctx) } // ExportWedgeWorker adds a goroutine to the engine's WaitGroup // that never observes cancellation and returns only when release // is closed. It stands in for a worker stuck inside a delivery // target that never returns, which is the only way stop can be // made to outlast its context. func (e *Engine) ExportWedgeWorker(release <-chan struct{}) { e.wg.Go(func() { <-release }) } // ExportInflightHeld reports how many deliveries the engine currently // owns, so a test can prove ownership is released rather than leaked. func (e *Engine) ExportInflightHeld() int { return e.inflight.held() } // ExportRetainDelivery takes the first reference on a delivery, as the // queueing side does. It lets a test put a delivery into the state a // worker or a full channel would, without running the pool. func (e *Engine) ExportRetainDelivery(deliveryID string) bool { return e.inflight.retainIdle(deliveryID) } // ExportRecoverRetryingDeliveries exposes recoverRetryingDeliveries. func (e *Engine) ExportRecoverRetryingDeliveries( webhookDB *gorm.DB, webhookID string, ) { e.recoverRetryingDeliveries(webhookDB, webhookID) } // ExportFailMissingTarget exposes failMissingTarget, so a test can hand // it a delivery as a batch read it earlier. func (e *Engine) ExportFailMissingTarget( webhookDB *gorm.DB, webhookID string, d *database.Delivery, ) { e.failMissingTarget(webhookDB, webhookID, d) } // ExportSendRecoveredDeliveries exposes sendRecoveredDeliveries, so a // test can hand it a target map that lacks a delivery's target. func (e *Engine) ExportSendRecoveredDeliveries( ctx context.Context, webhookDB *gorm.DB, deliveries []database.Delivery, webhookID string, targetMap map[string]database.Target, settled map[string]struct{}, ) { e.sendRecoveredDeliveries( ctx, webhookDB, deliveries, webhookID, targetMap, settled, ) } // ExportDeliveryCh returns the delivery channel. func (e *Engine) ExportDeliveryCh() chan Task { return e.deliveryCh } // ExportRetryCh returns the retry channel. func (e *Engine) ExportRetryCh() chan Task { return e.retryCh } // NewTestEngine creates an Engine for unit tests without // database dependencies. Its archive writers have no data // directory, so a delivery to a database target fails. func NewTestEngine( log *slog.Logger, client *http.Client, workers int, ) *Engine { e := &Engine{ log: log, deliveryCh: make(chan Task, deliveryChannelSize), retryCh: make(chan Task, retryChannelSize), workers: workers, mtr: metrics.New(prometheus.NewRegistry()), archives: archive.NewWriters(archive.WritersParams{Logger: log}), } e.initTargets(client) return e } // NewTestEngineSmallRetry creates an Engine with a tiny // retry channel buffer for overflow testing. func NewTestEngineSmallRetry( log *slog.Logger, ) *Engine { e := &Engine{ log: log, retryCh: make(chan Task, 1), mtr: metrics.New(prometheus.NewRegistry()), archives: archive.NewWriters(archive.WritersParams{Logger: log}), } e.initTargets(nil) return e } // NewTestEngineWithDB creates an Engine with a real // database and dbManager for integration tests, and archive // writers of its own over the same two. func NewTestEngineWithDB( db *database.Database, dbMgr *database.WebhookDBManager, log *slog.Logger, client *http.Client, workers int, ) *Engine { e := &Engine{ database: db, dbManager: dbMgr, log: log, deliveryCh: make(chan Task, deliveryChannelSize), retryCh: make(chan Task, retryChannelSize), workers: workers, mtr: metrics.New(prometheus.NewRegistry()), archives: archive.NewWriters(archive.WritersParams{ Database: db, DBManager: dbMgr, Logger: log, }), } e.initTargets(client) return e } // ExportArchives returns the engine's archive writers. func (e *Engine) ExportArchives() *archive.Writers { return e.archives } // ExportSetMetrics substitutes the engine's metric set, so a test can // assert on collectors registered on a registry it holds. func (e *Engine) ExportSetMetrics(mtr *metrics.Set) { e.mtr = mtr } // ExportSampleQueueDepths runs one queue depth sample synchronously. func (e *Engine) ExportSampleQueueDepths(ctx context.Context) { e.sampleQueueDepths(ctx) } // NewTestCircuitBreaker creates a CircuitBreaker with // custom settings for testing. func NewTestCircuitBreaker( threshold int, cooldown time.Duration, ) *CircuitBreaker { return &CircuitBreaker{ state: CircuitClosed, threshold: threshold, cooldown: cooldown, } }