Skip to content

Commit 63215d1

Browse files
committed
Merge latest origin/main into feat/read-receipts
Preserve read-receipt partial updates while incorporating webhook hardening and repository-wide Go vet fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
2 parents 879230b + 8a03f4f commit 63215d1

90 files changed

Lines changed: 900 additions & 1508 deletions

File tree

Some content is hidden

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

.github/workflows/api.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ jobs:
7474
docker compose wait seed || true
7575
sleep 2
7676
77+
- name: Run Handler Integration Tests
78+
working-directory: ./api
79+
env:
80+
USER_API_KEY: test-user-api-key
81+
run: go test -tags integration -v -timeout 60s ./pkg/handlers
82+
7783
- name: Run Integration Tests
7884
working-directory: ./tests
7985
run: go test -v -timeout 300s ./...

api/pkg/cache/memory_cache.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package cache
22

33
import (
44
"context"
5-
"fmt"
65
"time"
76

87
"github.com/NdoleStudio/httpsms/pkg/telemetry"
@@ -31,7 +30,7 @@ func (cache *memoryCache) Get(ctx context.Context, key string) (value string, er
3130

3231
response, ok := cache.store.Get(key)
3332
if !ok {
34-
return "", stacktrace.NewError(fmt.Sprintf("no item found in cache with key [%s]", key))
33+
return "", stacktrace.NewError("no item found in cache with key [%s]", key)
3534
}
3635

3736
return response.(string), nil

api/pkg/cache/redis_cache.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cache
33
import (
44
"context"
55
"errors"
6-
"fmt"
76
"time"
87

98
"github.com/NdoleStudio/httpsms/pkg/telemetry"
@@ -32,10 +31,10 @@ func (cache *redisCache) Get(ctx context.Context, key string) (value string, err
3231

3332
response, err := cache.client.Get(ctx, key).Result()
3433
if errors.Is(err, redis.Nil) {
35-
return "", stacktrace.Propagate(err, fmt.Sprintf("no item found in redis with key [%s]", key))
34+
return "", stacktrace.Propagate(err, "no item found in redis with key [%s]", key)
3635
}
3736
if err != nil {
38-
return "", stacktrace.Propagate(err, fmt.Sprintf("cannot get item in redis with key [%s]", key))
37+
return "", stacktrace.Propagate(err, "cannot get item in redis with key [%s]", key)
3938
}
4039
return response, nil
4140
}

api/pkg/di/container.go

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -279,11 +279,11 @@ func (container *Container) DedicatedDB() (db *gorm.DB) {
279279

280280
container.logger.Debug(fmt.Sprintf("Running migrations for dedicated [%T]", db))
281281
if err = db.AutoMigrate(&entities.Heartbeat{}); err != nil {
282-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Heartbeat{})))
282+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.Heartbeat{}))
283283
}
284284

285285
if err = db.AutoMigrate(&entities.HeartbeatMonitor{}); err != nil {
286-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.HeartbeatMonitor{})))
286+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.HeartbeatMonitor{}))
287287
}
288288

289289
return container.dedicatedDB
@@ -371,47 +371,47 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (
371371
}
372372

373373
if err = db.AutoMigrate(&entities.Message{}); err != nil {
374-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Message{})))
374+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.Message{}))
375375
}
376376

377377
if err = db.AutoMigrate(&entities.MessageThread{}); err != nil {
378-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.MessageThread{})))
378+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.MessageThread{}))
379379
}
380380

381381
if err = db.AutoMigrate(&entities.User{}); err != nil {
382-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.User{})))
382+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.User{}))
383383
}
384384

385385
if err = db.AutoMigrate(&entities.MessageSendSchedule{}); err != nil {
386-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.MessageSendSchedule{})))
386+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.MessageSendSchedule{}))
387387
}
388388

389389
if err = db.AutoMigrate(&entities.Phone{}); err != nil {
390-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Phone{})))
390+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.Phone{}))
391391
}
392392

393393
if err = db.AutoMigrate(&entities.PhoneNotification{}); err != nil {
394-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.PhoneNotification{})))
394+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.PhoneNotification{}))
395395
}
396396

397397
if err = db.AutoMigrate(&entities.BillingUsage{}); err != nil {
398-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.BillingUsage{})))
398+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.BillingUsage{}))
399399
}
400400

401401
if err = db.AutoMigrate(&entities.Webhook{}); err != nil {
402-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Webhook{})))
402+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.Webhook{}))
403403
}
404404

405405
if err = db.AutoMigrate(&entities.Discord{}); err != nil {
406-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Discord{})))
406+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.Discord{}))
407407
}
408408

409409
if err = db.AutoMigrate(&entities.Integration3CX{}); err != nil {
410-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Integration3CX{})))
410+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.Integration3CX{}))
411411
}
412412

413413
if err = db.AutoMigrate(&entities.PhoneAPIKey{}); err != nil {
414-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.PhoneAPIKey{})))
414+
container.logger.Fatal(stacktrace.Propagate(err, "cannot migrate %T", &entities.PhoneAPIKey{}))
415415
}
416416

417417
return container.db
@@ -423,8 +423,7 @@ func (container *Container) FirebaseApp() (app *firebase.App) {
423423

424424
app, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsJSON(container.FirebaseCredentials()))
425425
if err != nil {
426-
msg := "cannot initialize firebase application"
427-
container.logger.Fatal(stacktrace.Propagate(err, msg))
426+
container.logger.Fatal(stacktrace.Propagate(err, "cannot initialize firebase application"))
428427
}
429428
return app
430429
}
@@ -445,7 +444,7 @@ func (container *Container) Cache() cache.Cache {
445444
container.logger.Debug("creating cache.Cache")
446445
opt, err := redis.ParseURL(os.Getenv("REDIS_URL"))
447446
if err != nil {
448-
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot parse redis url [%s]", os.Getenv("REDIS_URL"))))
447+
container.logger.Fatal(stacktrace.Propagate(err, "cannot parse redis url [%s]", os.Getenv("REDIS_URL")))
449448
}
450449
if strings.HasPrefix(os.Getenv("REDIS_URL"), "rediss://") {
451450
opt.TLSConfig = &tls.Config{
@@ -473,8 +472,7 @@ func (container *Container) FirebaseAuthClient() (client *auth.Client) {
473472
container.logger.Debug(fmt.Sprintf("creating %T", client))
474473
authClient, err := container.FirebaseApp().Auth(context.Background())
475474
if err != nil {
476-
msg := "cannot initialize firebase auth client"
477-
container.logger.Fatal(stacktrace.Propagate(err, msg))
475+
container.logger.Fatal(stacktrace.Propagate(err, "cannot initialize firebase auth client"))
478476
}
479477
return authClient
480478
}
@@ -553,8 +551,7 @@ func (container *Container) FCMClient() services.FCMClient {
553551
container.logger.Debug("creating FirebaseFCMClient")
554552
messagingClient, err := container.FirebaseApp().Messaging(context.Background())
555553
if err != nil {
556-
msg := "cannot initialize firebase messaging client"
557-
container.logger.Fatal(stacktrace.Propagate(err, msg))
554+
container.logger.Fatal(stacktrace.Propagate(err, "cannot initialize firebase messaging client"))
558555
}
559556
return services.NewFirebaseFCMClient(messagingClient)
560557
}

api/pkg/emails/event_payload_formatter.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func formatEventPayload(payload string) (string, template.HTML) {
2323
content = highlightEventPayloadJSON(formattedPayload)
2424
}
2525

26-
// Every payload token is escaped before this trusted wrapper is constructed.
26+
// #nosec G203 -- every dynamic payload token is escaped before the static wrapper is added.
2727
richPayload := template.HTML(`<pre style="` + eventPayloadCodeBlockStyle + `">` + content + `</pre>`)
2828
return formattedPayload, richPayload
2929
}

api/pkg/emails/hermes_notification_email_factory.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,14 @@ func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities.
123123
return nil, stacktrace.Propagate(err, "cannot generate html email")
124124
}
125125

126-
text, err := factory.generator.GeneratePlainText(email)
126+
eventPayloadEntryIndex := len(email.Body.Dictionary) - 1
127+
text, err := generateWebhookSendFailedPlainText(func(value string) (string, error) {
128+
email.Body.Dictionary[eventPayloadEntryIndex].Value = value
129+
return factory.generator.GeneratePlainText(email)
130+
}, formattedPayload)
127131
if err != nil {
128132
return nil, stacktrace.Propagate(err, "cannot generate text email")
129133
}
130-
// Hermes/html2text collapses dictionary whitespace, so restore the payload after plain-text generation.
131-
text = replaceWebhookSendFailedEventPayloadPlaceholder(text, formattedPayload)
132134

133135
return &Email{
134136
ToEmail: user.Email,
@@ -138,6 +140,21 @@ func (factory *hermesNotificationEmailFactory) WebhookSendFailed(user *entities.
138140
}, nil
139141
}
140142

143+
func generateWebhookSendFailedPlainText(generate func(string) (string, error), formattedPayload string) (string, error) {
144+
text, err := generate(webhookSendFailedEventPayloadPlaceholder)
145+
if err != nil {
146+
return "", err
147+
}
148+
149+
if strings.Contains(text, webhookSendFailedEventPayloadPlaceholder) {
150+
// Hermes/html2text collapses dictionary whitespace, so restore the payload after plain-text generation.
151+
return replaceWebhookSendFailedEventPayloadPlaceholder(text, formattedPayload), nil
152+
}
153+
154+
// Preserve the real payload if a Hermes change stops carrying the placeholder through unchanged.
155+
return generate(formattedPayload)
156+
}
157+
141158
func replaceWebhookSendFailedEventPayloadPlaceholder(text string, formattedPayload string) string {
142159
before, after, found := strings.Cut(text, webhookSendFailedEventPayloadPlaceholder)
143160
if !found {

api/pkg/emails/hermes_notification_email_factory_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func TestWebhookSendFailedFormatsOnlyEventPayload(t *testing.T) {
5050
assert.Contains(t, email.Text, `"retry": false`)
5151
assert.NotContains(t, email.Text, "<pre")
5252
assert.NotContains(t, email.Text, webhookSendFailedEventPayloadPlaceholder)
53+
assert.NotContains(t, email.HTML, webhookSendFailedEventPayloadPlaceholder)
5354
}
5455

5556
func TestWebhookSendFailedPreservesNonJSONEventPayload(t *testing.T) {
@@ -76,6 +77,24 @@ func TestWebhookSendFailedPreservesNonJSONEventPayload(t *testing.T) {
7677
assert.Contains(t, email.Text, "line one\n line two")
7778
}
7879

80+
func TestGenerateWebhookSendFailedPlainTextFallsBackToFormattedPayload(t *testing.T) {
81+
formattedPayload := "{\n \"message\": \"hello\"\n}"
82+
values := make([]string, 0, 2)
83+
generate := func(value string) (string, error) {
84+
values = append(values, value)
85+
if len(values) == 1 {
86+
return "plain text without the placeholder", nil
87+
}
88+
return value, nil
89+
}
90+
91+
text, err := generateWebhookSendFailedPlainText(generate, formattedPayload)
92+
93+
require.NoError(t, err)
94+
assert.Equal(t, formattedPayload, text)
95+
assert.Equal(t, []string{webhookSendFailedEventPayloadPlaceholder, formattedPayload}, values)
96+
}
97+
7998
func TestReplaceWebhookSendFailedEventPayloadPlaceholder(t *testing.T) {
8099
tests := []struct {
81100
name string

api/pkg/handlers/attachment_handler.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,7 @@ func (h *AttachmentHandler) GetAttachment(c fiber.Ctx) error {
6666

6767
data, err := h.storage.Download(ctx, path)
6868
if err != nil {
69-
msg := fmt.Sprintf("cannot download attachment from path [%s]", path)
70-
ctxLogger.Warn(stacktrace.Propagate(err, msg))
69+
ctxLogger.Warn(stacktrace.Propagate(err, "cannot download attachment from path [%s]", path))
7170
if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
7271
return h.responseNotFound(c, "attachment not found")
7372
}

api/pkg/handlers/billing_handler.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,21 +65,18 @@ func (h *BillingHandler) UsageHistory(c fiber.Ctx) error {
6565

6666
var request requests.BillingUsageHistory
6767
if err := c.Bind().Query(&request); err != nil {
68-
msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.Body(), request)
69-
ctxLogger.Warn(stacktrace.Propagate(err, msg))
68+
ctxLogger.Warn(stacktrace.Propagate(err, "cannot marshall params [%s] into %T", c.Body(), request))
7069
return h.responseBadRequest(c, err)
7170
}
7271

7372
if errors := h.validator.ValidateHistory(ctx, request.Sanitize()); len(errors) != 0 {
74-
msg := fmt.Sprintf("validation errors [%s], while fetching heartbeats [%+#v]", spew.Sdump(errors), request)
75-
ctxLogger.Warn(stacktrace.NewError(msg))
73+
ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while fetching heartbeats [%+#v]", spew.Sdump(errors), request))
7674
return h.responseUnprocessableEntity(c, errors, "validation errors while fetching usage history")
7775
}
7876

7977
heartbeats, err := h.service.GetUsageHistory(ctx, h.userIDFomContext(c), request.ToIndexParams())
8078
if err != nil {
81-
msg := fmt.Sprintf("cannot get billing usage history with params [%+#v]", request)
82-
ctxLogger.Error(stacktrace.Propagate(err, msg))
79+
ctxLogger.Error(stacktrace.Propagate(err, "cannot get billing usage history with params [%+#v]", request))
8380
return h.responseInternalServerError(c)
8481
}
8582

@@ -107,8 +104,7 @@ func (h *BillingHandler) Usage(c fiber.Ctx) error {
107104

108105
billingUsage, err := h.service.GetCurrentUsage(ctx, h.userIDFomContext(c))
109106
if err != nil {
110-
msg := fmt.Sprintf("cannot get current usage record for user [%s]", h.userFromContext(c))
111-
ctxLogger.Error(stacktrace.Propagate(err, msg))
107+
ctxLogger.Error(stacktrace.Propagate(err, "cannot get current usage record for user [%s]", h.userFromContext(c)))
112108
return h.responseInternalServerError(c)
113109
}
114110

api/pkg/handlers/bulk_message_handler.go

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,7 @@ func (h *BulkMessageHandler) Index(c fiber.Ctx) error {
6767

6868
orders, err := h.messageService.GetBulkMessages(ctx, h.userIDFomContext(c))
6969
if err != nil {
70-
msg := fmt.Sprintf("cannot fetch bulk messages for user [%s]", h.userIDFomContext(c))
71-
ctxLogger.Error(stacktrace.Propagate(err, msg))
70+
ctxLogger.Error(stacktrace.Propagate(err, "cannot fetch bulk messages for user [%s]", h.userIDFomContext(c)))
7271
return h.responseInternalServerError(c)
7372
}
7473

@@ -95,20 +94,18 @@ func (h *BulkMessageHandler) Store(c fiber.Ctx) error {
9594

9695
file, err := c.FormFile("document")
9796
if err != nil {
98-
msg := fmt.Sprintf("cannot fetch file with name [%s] from request", "document")
99-
ctxLogger.Warn(stacktrace.Propagate(err, msg))
97+
ctxLogger.Warn(stacktrace.Propagate(err, "cannot fetch file with name [%s] from request", "document"))
10098
return h.responseBadRequest(c, err)
10199
}
102100

103101
messages, userLocation, validationErrors := h.validator.ValidateStore(ctx, h.userIDFomContext(c), file)
104102
if len(validationErrors) != 0 {
105-
msg := fmt.Sprintf("validation errors [%s], while sending bulk sms from CSV file [%s] for [%s]", spew.Sdump(validationErrors), file.Filename, h.userIDFomContext(c))
106-
ctxLogger.Warn(stacktrace.NewError(msg))
103+
ctxLogger.Warn(stacktrace.NewError("validation errors [%s], while sending bulk sms from CSV file [%s] for [%s]", spew.Sdump(validationErrors), file.Filename, h.userIDFomContext(c)))
107104
return h.responseUnprocessableEntity(c, validationErrors, "validation errors while sending bulk SMS")
108105
}
109106

110107
if msg := h.billingService.IsEntitledWithCount(ctx, h.userIDFomContext(c), uint(len(messages))); msg != nil {
111-
ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("user with ID [%s] is not entitled to send [%d] messages", h.userIDFomContext(c), len(messages))))
108+
ctxLogger.Warn(stacktrace.NewError("user with ID [%s] is not entitled to send [%d] messages", h.userIDFomContext(c), len(messages)))
112109
return h.responsePaymentRequired(c, *msg)
113110
}
114111

@@ -135,8 +132,8 @@ func (h *BulkMessageHandler) Store(c fiber.Ctx) error {
135132
)
136133
if err != nil {
137134
count.Add(-1)
138-
msg := fmt.Sprintf("cannot send message with payload [%s] at index [%d]", spew.Sdump(message), index)
139-
ctxLogger.Error(stacktrace.Propagate(err, msg))
135+
136+
ctxLogger.Error(stacktrace.Propagate(err, "cannot send message with payload [%s] at index [%d]", spew.Sdump(message), index))
140137
}
141138
wg.Done()
142139
}(message, perPhoneIndex)

0 commit comments

Comments
 (0)