Skip to content

Commit 925d363

Browse files
committed
feat(webhook): ajout de la prise en charge des webhooks signés
- Envoi des événements vers des URLs configurées - Signature HMAC-SHA256 via en-tête X-Signature (secret partagé) - Retentatives avec backoff exponentiel et jitter - Timeout réseau et gestion des erreurs/transitoires - Idempotence par event_id et journalisation structurée - Paramètres: WEBHOOK_URLS, WEBHOOK_SECRET, WEBHOOK_TIMEOUT_MS, WEBHOOK_MAX_RETRIES
1 parent 77018a9 commit 925d363

29 files changed

+2263
-50
lines changed
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
package services
3+
4+
import (
5+
"context"
6+
"testing"
7+
8+
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
9+
)
10+
11+
// mockDocRepo is a simple in-memory mock for testing document duplication scenarios
12+
type mockDocRepo struct {
13+
documents map[string]*models.Document
14+
callCount int
15+
}
16+
17+
func newMockDocRepo() *mockDocRepo {
18+
return &mockDocRepo{
19+
documents: make(map[string]*models.Document),
20+
}
21+
}
22+
23+
func (m *mockDocRepo) Create(ctx context.Context, docID string, input models.DocumentInput, createdBy string) (*models.Document, error) {
24+
m.callCount++
25+
doc := &models.Document{
26+
DocID: docID,
27+
Title: input.Title,
28+
URL: input.URL,
29+
Checksum: input.Checksum,
30+
ChecksumAlgorithm: input.ChecksumAlgorithm,
31+
Description: input.Description,
32+
CreatedBy: createdBy,
33+
}
34+
m.documents[docID] = doc
35+
return doc, nil
36+
}
37+
38+
func (m *mockDocRepo) GetByDocID(ctx context.Context, docID string) (*models.Document, error) {
39+
doc, exists := m.documents[docID]
40+
if !exists {
41+
return nil, nil
42+
}
43+
return doc, nil
44+
}
45+
46+
func (m *mockDocRepo) FindByReference(ctx context.Context, ref string, refType string) (*models.Document, error) {
47+
// Search by reference logic
48+
switch refType {
49+
case "reference":
50+
// Search by doc_id
51+
return m.GetByDocID(ctx, ref)
52+
case "url":
53+
// Search by URL
54+
for _, doc := range m.documents {
55+
if doc.URL == ref {
56+
return doc, nil
57+
}
58+
}
59+
case "path":
60+
// Search by URL (paths stored in URL field)
61+
for _, doc := range m.documents {
62+
if doc.URL == ref {
63+
return doc, nil
64+
}
65+
}
66+
}
67+
return nil, nil
68+
}
69+
70+
// TestFindOrCreateDocument_SameReferenceTwice tests that calling FindOrCreateDocument
71+
// with the same reference twice does NOT create duplicate documents
72+
func TestFindOrCreateDocument_SameReferenceTwice(t *testing.T) {
73+
ctx := context.Background()
74+
repo := newMockDocRepo()
75+
service := NewDocumentService(repo, nil)
76+
77+
reference := "doc-123"
78+
79+
// First call - should create document
80+
doc1, isNew1, err := service.FindOrCreateDocument(ctx, reference)
81+
if err != nil {
82+
t.Fatalf("First FindOrCreateDocument failed: %v", err)
83+
}
84+
85+
if !isNew1 {
86+
t.Error("First call should return isNew=true")
87+
}
88+
89+
if doc1.DocID != reference {
90+
t.Errorf("Expected doc_id=%s, got %s", reference, doc1.DocID)
91+
}
92+
93+
// Second call with SAME reference - should find existing document
94+
doc2, isNew2, err := service.FindOrCreateDocument(ctx, reference)
95+
if err != nil {
96+
t.Fatalf("Second FindOrCreateDocument failed: %v", err)
97+
}
98+
99+
if isNew2 {
100+
t.Error("Second call should return isNew=false (document already exists)")
101+
}
102+
103+
if doc2.DocID != doc1.DocID {
104+
t.Errorf("Expected same doc_id=%s, got different doc_id=%s", doc1.DocID, doc2.DocID)
105+
}
106+
107+
// Verify only ONE document was created
108+
if len(repo.documents) != 1 {
109+
t.Errorf("Expected 1 document in repository, got %d", len(repo.documents))
110+
}
111+
112+
// Verify Create was called only ONCE
113+
if repo.callCount != 1 {
114+
t.Errorf("Expected Create to be called 1 time, got %d calls", repo.callCount)
115+
}
116+
}
117+
118+
// TestFindOrCreateDocument_URLReference tests that URL references are properly deduplicated
119+
func TestFindOrCreateDocument_URLReference(t *testing.T) {
120+
ctx := context.Background()
121+
repo := newMockDocRepo()
122+
service := NewDocumentService(repo, nil)
123+
124+
urlRef := "https://example.com/policy.pdf"
125+
126+
// First call - should create document
127+
doc1, isNew1, err := service.FindOrCreateDocument(ctx, urlRef)
128+
if err != nil {
129+
t.Fatalf("First FindOrCreateDocument failed: %v", err)
130+
}
131+
132+
if !isNew1 {
133+
t.Error("First call should return isNew=true")
134+
}
135+
136+
firstDocID := doc1.DocID
137+
138+
// Second call with SAME URL - should find existing document
139+
doc2, isNew2, err := service.FindOrCreateDocument(ctx, urlRef)
140+
if err != nil {
141+
t.Fatalf("Second FindOrCreateDocument failed: %v", err)
142+
}
143+
144+
if isNew2 {
145+
t.Error("Second call should return isNew=false (document with this URL already exists)")
146+
}
147+
148+
if doc2.DocID != firstDocID {
149+
t.Errorf("Expected same doc_id=%s, got different doc_id=%s", firstDocID, doc2.DocID)
150+
}
151+
152+
// Verify only ONE document was created
153+
if len(repo.documents) != 1 {
154+
t.Errorf("Expected 1 document in repository, got %d", len(repo.documents))
155+
}
156+
}
157+
158+
// TestCreateDocument_AlwaysCreatesNew demonstrates the problematic behavior
159+
// of CreateDocument (always creates new documents without checking)
160+
func TestCreateDocument_AlwaysCreatesNew(t *testing.T) {
161+
ctx := context.Background()
162+
repo := newMockDocRepo()
163+
service := NewDocumentService(repo, nil)
164+
165+
reference := "doc-456"
166+
167+
// First call
168+
doc1, err := service.CreateDocument(ctx, CreateDocumentRequest{Reference: reference})
169+
if err != nil {
170+
t.Fatalf("First CreateDocument failed: %v", err)
171+
}
172+
173+
firstDocID := doc1.DocID
174+
175+
// Second call with SAME reference
176+
doc2, err := service.CreateDocument(ctx, CreateDocumentRequest{Reference: reference})
177+
if err != nil {
178+
t.Fatalf("Second CreateDocument failed: %v", err)
179+
}
180+
181+
secondDocID := doc2.DocID
182+
183+
// This is the PROBLEM: CreateDocument creates different doc_ids for the same reference
184+
if firstDocID == secondDocID {
185+
t.Error("CreateDocument generated the same doc_id twice (unlikely but possible)")
186+
}
187+
188+
// This demonstrates the bug: we now have 2+ documents for the same reference
189+
if len(repo.documents) < 2 {
190+
t.Logf("WARNING: CreateDocument was called twice but created %d documents", len(repo.documents))
191+
}
192+
193+
t.Logf("CreateDocument behavior: Reference '%s' created doc_id '%s' and '%s' (DUPLICATION)",
194+
reference, firstDocID, secondDocID)
195+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
package services
3+
4+
import (
5+
"context"
6+
"crypto/rand"
7+
"encoding/hex"
8+
"fmt"
9+
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
10+
"github.com/btouchard/ackify-ce/backend/pkg/logger"
11+
)
12+
13+
// Interfaces kept local to application layer
14+
type webhookRepo interface {
15+
ListActiveByEvent(ctx context.Context, event string) ([]*models.Webhook, error)
16+
}
17+
18+
type webhookDeliveryRepo interface {
19+
Enqueue(ctx context.Context, input models.WebhookDeliveryInput) (*models.WebhookDelivery, error)
20+
}
21+
22+
// WebhookPublisher publishes events to active webhooks via delivery queue
23+
type WebhookPublisher struct {
24+
repo webhookRepo
25+
deliveries webhookDeliveryRepo
26+
}
27+
28+
func NewWebhookPublisher(repo webhookRepo, deliveries webhookDeliveryRepo) *WebhookPublisher {
29+
return &WebhookPublisher{repo: repo, deliveries: deliveries}
30+
}
31+
32+
// Publish enqueues deliveries for all webhooks subscribed to the event
33+
func (p *WebhookPublisher) Publish(ctx context.Context, eventType string, payload map[string]interface{}) error {
34+
logger.Logger.Debug("Publishing event", "event", eventType)
35+
hooks, err := p.repo.ListActiveByEvent(ctx, eventType)
36+
if err != nil {
37+
return fmt.Errorf("failed to list webhooks: %w", err)
38+
}
39+
if len(hooks) == 0 {
40+
return nil
41+
}
42+
43+
eventID := newEventID()
44+
for _, h := range hooks {
45+
input := models.WebhookDeliveryInput{
46+
WebhookID: h.ID,
47+
EventType: eventType,
48+
EventID: eventID,
49+
Payload: payload,
50+
Priority: 0,
51+
MaxRetries: 6,
52+
}
53+
if _, err := p.deliveries.Enqueue(ctx, input); err != nil {
54+
logger.Logger.Warn("Failed to enqueue webhook delivery", "webhook_id", h.ID, "error", err.Error())
55+
}
56+
}
57+
return nil
58+
}
59+
60+
func newEventID() string {
61+
b := make([]byte, 16)
62+
_, _ = rand.Read(b)
63+
// Format hex with dashes like UUID v4 (not asserting version bits here to avoid extra ops)
64+
hexStr := hex.EncodeToString(b)
65+
return hexStr[0:8] + "-" + hexStr[8:12] + "-" + hexStr[12:16] + "-" + hexStr[16:20] + "-" + hexStr[20:32]
66+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
package services
3+
4+
import (
5+
"context"
6+
"testing"
7+
8+
"github.com/btouchard/ackify-ce/backend/internal/domain/models"
9+
)
10+
11+
type fakeWebhookRepo struct {
12+
hooks []*models.Webhook
13+
err error
14+
}
15+
16+
func (f *fakeWebhookRepo) ListActiveByEvent(_ context.Context, _ string) ([]*models.Webhook, error) {
17+
return f.hooks, f.err
18+
}
19+
20+
type fakeDeliveryRepo struct{ inputs []models.WebhookDeliveryInput }
21+
22+
func (f *fakeDeliveryRepo) Enqueue(_ context.Context, in models.WebhookDeliveryInput) (*models.WebhookDelivery, error) {
23+
f.inputs = append(f.inputs, in)
24+
return &models.WebhookDelivery{ID: int64(len(f.inputs)), WebhookID: in.WebhookID, EventType: in.EventType, EventID: in.EventID}, nil
25+
}
26+
27+
func TestWebhookPublisher_Publish(t *testing.T) {
28+
hooks := []*models.Webhook{{ID: 1, Active: true, Events: []string{"document.created"}}, {ID: 2, Active: true, Events: []string{"document.created"}}}
29+
repo := &fakeWebhookRepo{hooks: hooks}
30+
drepo := &fakeDeliveryRepo{}
31+
p := NewWebhookPublisher(repo, drepo)
32+
payload := map[string]interface{}{"doc_id": "abc123", "title": "Title"}
33+
if err := p.Publish(context.Background(), "document.created", payload); err != nil {
34+
t.Fatalf("Publish error: %v", err)
35+
}
36+
if len(drepo.inputs) != 2 {
37+
t.Fatalf("expected 2 enqueues, got %d", len(drepo.inputs))
38+
}
39+
// ensure event type propagated
40+
if drepo.inputs[0].EventType != "document.created" || drepo.inputs[1].EventType != "document.created" {
41+
t.Errorf("unexpected event types: %#v", drepo.inputs)
42+
}
43+
// ensure payload reference equality not required, but keys exist
44+
for _, in := range drepo.inputs {
45+
if in.Payload["doc_id"] != "abc123" {
46+
t.Error("payload doc_id mismatch")
47+
}
48+
}
49+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
package models
3+
4+
import (
5+
"encoding/json"
6+
"time"
7+
)
8+
9+
type Webhook struct {
10+
Title string `json:"title"`
11+
ID int64 `json:"id"`
12+
TargetURL string `json:"targetUrl"`
13+
Secret string `json:"-"`
14+
Active bool `json:"active"`
15+
Events []string `json:"events"`
16+
Headers map[string]string `json:"headers,omitempty"`
17+
Description string `json:"description,omitempty"`
18+
CreatedBy string `json:"createdBy,omitempty"`
19+
CreatedAt time.Time `json:"createdAt"`
20+
UpdatedAt time.Time `json:"updatedAt"`
21+
LastDeliveredAt *time.Time `json:"lastDeliveredAt,omitempty"`
22+
FailureCount int `json:"failureCount"`
23+
}
24+
25+
type WebhookInput struct {
26+
Title string `json:"title"`
27+
TargetURL string `json:"targetUrl"`
28+
Secret string `json:"secret"`
29+
Active bool `json:"active"`
30+
Events []string `json:"events"`
31+
Headers map[string]string `json:"headers,omitempty"`
32+
Description string `json:"description,omitempty"`
33+
CreatedBy string `json:"createdBy,omitempty"`
34+
}
35+
36+
// NullRawMessage mirrors Null handling used elsewhere for JSONB columns
37+
// Note: uses existing NullRawMessage from models/email_queue.go
38+
39+
type WebhookDeliveryStatus string
40+
41+
const (
42+
WebhookStatusPending WebhookDeliveryStatus = "pending"
43+
WebhookStatusProcessing WebhookDeliveryStatus = "processing"
44+
WebhookStatusDelivered WebhookDeliveryStatus = "delivered"
45+
WebhookStatusFailed WebhookDeliveryStatus = "failed"
46+
WebhookStatusCancelled WebhookDeliveryStatus = "cancelled"
47+
)
48+
49+
type WebhookDelivery struct {
50+
ID int64 `json:"id"`
51+
WebhookID int64 `json:"webhookId"`
52+
EventType string `json:"eventType"`
53+
EventID string `json:"eventId"`
54+
Payload json.RawMessage `json:"payload"`
55+
Status WebhookDeliveryStatus `json:"status"`
56+
RetryCount int `json:"retryCount"`
57+
MaxRetries int `json:"maxRetries"`
58+
Priority int `json:"priority"`
59+
CreatedAt time.Time `json:"createdAt"`
60+
ScheduledFor time.Time `json:"scheduledFor"`
61+
ProcessedAt *time.Time `json:"processedAt,omitempty"`
62+
NextRetryAt *time.Time `json:"nextRetryAt,omitempty"`
63+
RequestHeaders NullRawMessage `json:"requestHeaders,omitempty"`
64+
ResponseStatus *int `json:"responseStatus,omitempty"`
65+
ResponseHeaders NullRawMessage `json:"responseHeaders,omitempty"`
66+
ResponseBody *string `json:"responseBody,omitempty"`
67+
LastError *string `json:"lastError,omitempty"`
68+
}
69+
70+
type WebhookDeliveryInput struct {
71+
WebhookID int64
72+
EventType string
73+
EventID string
74+
Payload map[string]interface{}
75+
Priority int
76+
MaxRetries int
77+
ScheduledFor *time.Time
78+
}

0 commit comments

Comments
 (0)