Skip to content

Commit 4e29d1f

Browse files
AchoArnoldCopilot
andcommitted
test: validate adapter notification JWT auth in emulator
The adapter emulator now requires and validates the phone-ID-signed JWT (Authorization: Bearer) that the API sends with every FCM-compatible notification, mirroring the webhook JWT validation already used in integration tests. - adapter-emulator: gateway registration now requires phone_id; notification_handler verifies the JWT (HS256, sub==phone_id, iss==api.httpsms.com) before recording/processing, rejecting invalid/missing tokens with 401. - emulator_test.go: updated existing tests to register phone_id and send valid tokens; added negative tests for missing auth and wrong signing secret. - helpers_test.go: setupAdapterPhone now upserts the phone before registering the gateway (so phone_id is known), and adds an assertAdapterNotificationJWT helper mirroring assertWebhookJWT. - adapter_integration_test.go: asserts the JWT on recorded message and heartbeat notifications. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 45ed9de9-a3ad-41cf-ad32-ebec28d9771c
1 parent 2c930ed commit 4e29d1f

8 files changed

Lines changed: 218 additions & 31 deletions

File tree

tests/adapter-emulator/control_handler.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const maxControlBodyBytes = 1024 * 1024
1414
type gatewayRegistration struct {
1515
PhoneNumber string `json:"phone_number"`
1616
PhoneAPIKey string `json:"phone_api_key"`
17+
PhoneID string `json:"phone_id"`
1718
}
1819

1920
type incomingMessageRequest struct {
@@ -39,8 +40,9 @@ func (instance *emulator) handleGatewayRegistration(writer http.ResponseWriter,
3940
}
4041
registration.PhoneNumber = strings.TrimSpace(registration.PhoneNumber)
4142
registration.PhoneAPIKey = strings.TrimSpace(registration.PhoneAPIKey)
42-
if registration.PhoneNumber == "" || registration.PhoneAPIKey == "" {
43-
writeControlError(writer, http.StatusBadRequest, errors.New("phone_number and phone_api_key are required"))
43+
registration.PhoneID = strings.TrimSpace(registration.PhoneID)
44+
if registration.PhoneNumber == "" || registration.PhoneAPIKey == "" || registration.PhoneID == "" {
45+
writeControlError(writer, http.StatusBadRequest, errors.New("phone_number, phone_api_key and phone_id are required"))
4446
return
4547
}
4648

tests/adapter-emulator/emulator.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@ import (
99
type gateway struct {
1010
PhoneNumber string
1111
PhoneAPIKey string
12+
PhoneID string
1213
}
1314

1415
type notificationRecord struct {
15-
GatewayID string `json:"gateway_id"`
16-
Data map[string]string `json:"data"`
17-
MessageID string `json:"message_id,omitempty"`
18-
Kind string `json:"kind"`
19-
Processed bool `json:"processed"`
20-
Error string `json:"error,omitempty"`
16+
GatewayID string `json:"gateway_id"`
17+
Data map[string]string `json:"data"`
18+
MessageID string `json:"message_id,omitempty"`
19+
Kind string `json:"kind"`
20+
Processed bool `json:"processed"`
21+
Error string `json:"error,omitempty"`
22+
Authorization string `json:"authorization,omitempty"`
2123
}
2224

2325
type emulator struct {
@@ -43,6 +45,7 @@ func (instance *emulator) registerGateway(gatewayID string, registration gateway
4345
instance.gateways[gatewayID] = gateway{
4446
PhoneNumber: registration.PhoneNumber,
4547
PhoneAPIKey: registration.PhoneAPIKey,
48+
PhoneID: registration.PhoneID,
4649
}
4750
}
4851

@@ -59,15 +62,17 @@ func (instance *emulator) recordNotification(
5962
data map[string]string,
6063
kind string,
6164
messageID string,
65+
authorization string,
6266
) *notificationRecord {
6367
instance.mu.Lock()
6468
defer instance.mu.Unlock()
6569

6670
record := &notificationRecord{
67-
GatewayID: gatewayID,
68-
Data: copyStringMap(data),
69-
MessageID: messageID,
70-
Kind: kind,
71+
GatewayID: gatewayID,
72+
Data: copyStringMap(data),
73+
MessageID: messageID,
74+
Kind: kind,
75+
Authorization: authorization,
7176
}
7277
instance.records = append(instance.records, record)
7378

tests/adapter-emulator/emulator_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,29 @@ import (
99
"strings"
1010
"sync"
1111
"testing"
12+
"time"
13+
14+
"github.com/golang-jwt/jwt/v5"
1215
)
1316

17+
const testGatewayPhoneID = "11111111-1111-1111-1111-111111111111"
18+
1419
func TestRecordNotificationCopiesRecords(t *testing.T) {
1520
t.Parallel()
1621

1722
instance := newEmulator("http://api.example", http.DefaultClient)
1823
instance.registerGateway("gateway-1", gatewayRegistration{
1924
PhoneNumber: "+18005550199",
2025
PhoneAPIKey: "phone-key",
26+
PhoneID: testGatewayPhoneID,
2127
})
2228

2329
record := instance.recordNotification(
2430
"gateway-1",
2531
map[string]string{"KEY_MESSAGE_ID": "message-1"},
2632
"message",
2733
"message-1",
34+
"Bearer test-token",
2835
)
2936
instance.markNotificationProcessed(record)
3037

@@ -90,6 +97,7 @@ func TestNotificationHandlerProcessesMessage(t *testing.T) {
9097
instance.registerGateway("gateway-1", gatewayRegistration{
9198
PhoneNumber: "+18005550199",
9299
PhoneAPIKey: "phone-key",
100+
PhoneID: testGatewayPhoneID,
93101
})
94102

95103
body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})
@@ -98,6 +106,7 @@ func TestNotificationHandlerProcessesMessage(t *testing.T) {
98106
"/notifications/gateway-1",
99107
bytes.NewReader(body),
100108
)
109+
request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID))
101110
response := httptest.NewRecorder()
102111
instance.notificationHandler().ServeHTTP(response, request)
103112
if response.Code != http.StatusNoContent {
@@ -146,13 +155,15 @@ func TestNotificationHandlerStoresHeartbeat(t *testing.T) {
146155
instance.registerGateway("gateway-1", gatewayRegistration{
147156
PhoneNumber: "+18005550199",
148157
PhoneAPIKey: "phone-key",
158+
PhoneID: testGatewayPhoneID,
149159
})
150160

151161
request := httptest.NewRequest(
152162
http.MethodPost,
153163
"/notifications/gateway-1",
154164
bytes.NewReader(callbackBody(t, map[string]string{"KEY_HEARTBEAT_ID": "heartbeat-1"})),
155165
)
166+
request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID))
156167
response := httptest.NewRecorder()
157168
instance.notificationHandler().ServeHTTP(response, request)
158169
if response.Code != http.StatusNoContent {
@@ -184,13 +195,15 @@ func TestNotificationHandlerRetainsProcessingFailure(t *testing.T) {
184195
instance.registerGateway("gateway-1", gatewayRegistration{
185196
PhoneNumber: "+18005550199",
186197
PhoneAPIKey: "phone-key",
198+
PhoneID: testGatewayPhoneID,
187199
})
188200

189201
request := httptest.NewRequest(
190202
http.MethodPost,
191203
"/notifications/gateway-1",
192204
bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})),
193205
)
206+
request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID))
194207
response := httptest.NewRecorder()
195208
instance.notificationHandler().ServeHTTP(response, request)
196209
if response.Code != http.StatusInternalServerError {
@@ -250,17 +263,20 @@ func TestNotificationHandlerProcessesRetryAfterFailure(t *testing.T) {
250263
instance.registerGateway("gateway-1", gatewayRegistration{
251264
PhoneNumber: "+18005550199",
252265
PhoneAPIKey: "phone-key",
266+
PhoneID: testGatewayPhoneID,
253267
})
254268
handler := instance.notificationHandler()
255269
body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})
256270

257271
firstRequest := httptest.NewRequest(http.MethodPost, "/notifications/gateway-1", bytes.NewReader(body))
272+
firstRequest.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID))
258273
firstResponse := httptest.NewRecorder()
259274
handler.ServeHTTP(firstResponse, firstRequest)
260275
if firstResponse.Code != http.StatusInternalServerError {
261276
t.Fatalf("first callback status = %d, want 500: %s", firstResponse.Code, firstResponse.Body.String())
262277
}
263278
secondRequest := httptest.NewRequest(http.MethodPost, "/notifications/gateway-1", bytes.NewReader(body))
279+
secondRequest.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID))
264280
secondResponse := httptest.NewRecorder()
265281
handler.ServeHTTP(secondResponse, secondRequest)
266282
if secondResponse.Code != http.StatusNoContent {
@@ -315,6 +331,7 @@ func TestControlHandlerRegistersGatewayAndReceivesIncomingMessage(t *testing.T)
315331
registration := performJSONRequest(t, handler, http.MethodPut, "/test/gateways/gateway-1", map[string]any{
316332
"phone_number": "+18005550199",
317333
"phone_api_key": "phone-key",
334+
"phone_id": testGatewayPhoneID,
318335
})
319336
if registration.Code != http.StatusNoContent {
320337
t.Fatalf("registration status = %d, want 204: %s", registration.Code, registration.Body.String())
@@ -368,18 +385,21 @@ func TestControlHandlerFiltersNotificationRecordsByMessageID(t *testing.T) {
368385
instance.registerGateway("gateway-1", gatewayRegistration{
369386
PhoneNumber: "+18005550199",
370387
PhoneAPIKey: "phone-key",
388+
PhoneID: testGatewayPhoneID,
371389
})
372390
instance.recordNotification(
373391
"gateway-1",
374392
map[string]string{"KEY_MESSAGE_ID": "message-1"},
375393
"message",
376394
"message-1",
395+
"Bearer test-token",
377396
)
378397
instance.recordNotification(
379398
"gateway-1",
380399
map[string]string{"KEY_MESSAGE_ID": "message-2"},
381400
"message",
382401
"message-2",
402+
"Bearer test-token",
383403
)
384404

385405
response := httptest.NewRecorder()
@@ -406,6 +426,76 @@ func TestControlHandlerFiltersNotificationRecordsByMessageID(t *testing.T) {
406426
}
407427
}
408428

429+
func TestNotificationHandlerRejectsMissingAuthorization(t *testing.T) {
430+
t.Parallel()
431+
432+
instance := newEmulator("http://api.example", http.DefaultClient)
433+
instance.registerGateway("gateway-1", gatewayRegistration{
434+
PhoneNumber: "+18005550199",
435+
PhoneAPIKey: "phone-key",
436+
PhoneID: testGatewayPhoneID,
437+
})
438+
439+
request := httptest.NewRequest(
440+
http.MethodPost,
441+
"/notifications/gateway-1",
442+
bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})),
443+
)
444+
response := httptest.NewRecorder()
445+
instance.notificationHandler().ServeHTTP(response, request)
446+
if response.Code != http.StatusUnauthorized {
447+
t.Fatalf("callback status = %d, want 401: %s", response.Code, response.Body.String())
448+
}
449+
if records := instance.listGatewayRecords("gateway-1"); len(records) != 0 {
450+
t.Fatalf("record count = %d, want 0 for an unauthenticated request", len(records))
451+
}
452+
}
453+
454+
func TestNotificationHandlerRejectsTokenSignedWithWrongSecret(t *testing.T) {
455+
t.Parallel()
456+
457+
instance := newEmulator("http://api.example", http.DefaultClient)
458+
instance.registerGateway("gateway-1", gatewayRegistration{
459+
PhoneNumber: "+18005550199",
460+
PhoneAPIKey: "phone-key",
461+
PhoneID: testGatewayPhoneID,
462+
})
463+
464+
request := httptest.NewRequest(
465+
http.MethodPost,
466+
"/notifications/gateway-1",
467+
bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})),
468+
)
469+
request.Header.Set("Authorization", validNotificationToken(t, "some-other-phone-id"))
470+
response := httptest.NewRecorder()
471+
instance.notificationHandler().ServeHTTP(response, request)
472+
if response.Code != http.StatusUnauthorized {
473+
t.Fatalf("callback status = %d, want 401: %s", response.Code, response.Body.String())
474+
}
475+
if records := instance.listGatewayRecords("gateway-1"); len(records) != 0 {
476+
t.Fatalf("record count = %d, want 0 for a request signed with the wrong secret", len(records))
477+
}
478+
}
479+
480+
func validNotificationToken(t *testing.T, phoneID string) string {
481+
t.Helper()
482+
483+
now := time.Now().UTC()
484+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
485+
Audience: []string{"https://adapter-emulator:9091/notifications/gateway-1"},
486+
ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)),
487+
IssuedAt: jwt.NewNumericDate(now),
488+
Issuer: notificationJWTIssuer,
489+
NotBefore: jwt.NewNumericDate(now.Add(-10 * time.Minute)),
490+
Subject: phoneID,
491+
})
492+
signed, err := token.SignedString([]byte(phoneID))
493+
if err != nil {
494+
t.Fatalf("sign notification token: %v", err)
495+
}
496+
return "Bearer " + signed
497+
}
498+
409499
func callbackBody(t *testing.T, data map[string]string) []byte {
410500
t.Helper()
411501

tests/adapter-emulator/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
module github.com/NdoleStudio/httpsms/tests/adapter-emulator
22

33
go 1.25.0
4+
5+
require github.com/golang-jwt/jwt/v5 v5.3.1

tests/adapter-emulator/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
2+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=

tests/adapter-emulator/notification_handler.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@ import (
66
"log"
77
"net/http"
88
"strings"
9+
10+
"github.com/golang-jwt/jwt/v5"
911
)
1012

1113
const maxCallbackBodyBytes = 1024 * 1024
1214

15+
// notificationJWTIssuer must match the issuer the httpSMS API signs adapter notification
16+
// tokens with (see api/pkg/services/http_notification_sender.go).
17+
const notificationJWTIssuer = "api.httpsms.com"
18+
1319
type callbackEnvelope struct {
1420
Message struct {
1521
Token string `json:"token"`
@@ -31,6 +37,13 @@ func (instance *emulator) handleNotification(writer http.ResponseWriter, request
3137
return
3238
}
3339

40+
authorization := request.Header.Get("Authorization")
41+
if err := verifyNotificationAuth(authorization, registeredGateway.PhoneID); err != nil {
42+
log.Printf("[ADAPTER] rejected notification for gateway=%s: %v", gatewayID, err)
43+
http.Error(writer, fmt.Sprintf("invalid notification token: %v", err), http.StatusUnauthorized)
44+
return
45+
}
46+
3447
request.Body = http.MaxBytesReader(writer, request.Body, maxCallbackBodyBytes)
3548
var envelope callbackEnvelope
3649
if err := json.NewDecoder(request.Body).Decode(&envelope); err != nil {
@@ -44,6 +57,7 @@ func (instance *emulator) handleNotification(writer http.ResponseWriter, request
4457
envelope.Message.Data,
4558
kind,
4659
messageID,
60+
authorization,
4761
)
4862
log.Printf(
4963
"[ADAPTER] callback gateway=%s data=%v",
@@ -94,3 +108,34 @@ func notificationKind(data map[string]string) (kind string, messageID string, er
94108
return "", "", fmt.Errorf("unsupported notification data")
95109
}
96110
}
111+
112+
// verifyNotificationAuth validates the JWT the httpSMS API signs notification requests with,
113+
// using the gateway's phone ID as the HMAC-SHA256 secret (see
114+
// api/pkg/services/http_notification_sender.go getAuthToken).
115+
func verifyNotificationAuth(authorization string, phoneID string) error {
116+
tokenString, ok := strings.CutPrefix(authorization, "Bearer ")
117+
if !ok || strings.TrimSpace(tokenString) == "" {
118+
return fmt.Errorf("missing bearer token")
119+
}
120+
121+
claims := jwt.RegisteredClaims{}
122+
token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) {
123+
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
124+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
125+
}
126+
return []byte(phoneID), nil
127+
})
128+
if err != nil {
129+
return fmt.Errorf("parse token: %w", err)
130+
}
131+
if !token.Valid {
132+
return fmt.Errorf("token is not valid")
133+
}
134+
if claims.Subject != phoneID {
135+
return fmt.Errorf("subject mismatch")
136+
}
137+
if claims.Issuer != notificationJWTIssuer {
138+
return fmt.Errorf("issuer mismatch")
139+
}
140+
return nil
141+
}

tests/adapter_integration_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ func TestAdapterGatewayOutgoingMessage(t *testing.T) {
3737
assert.Equal(t, "message", records[0].Kind)
3838
assert.True(t, records[0].Processed)
3939
assert.Equal(t, messageID, records[0].Data["KEY_MESSAGE_ID"])
40+
assertAdapterNotificationJWT(t, records[0], phone.PhoneID)
4041
}
4142

4243
func TestAdapterGatewayIncomingMessage(t *testing.T) {
@@ -79,6 +80,7 @@ func TestAdapterGatewayHeartbeatWakeUp(t *testing.T) {
7980
record := waitForAdapterHeartbeatRecord(t, phone.GatewayID, 30*time.Second)
8081
assert.Equal(t, "heartbeat", record.Kind)
8182
assert.NotEmpty(t, record.Data["KEY_HEARTBEAT_ID"])
83+
assertAdapterNotificationJWT(t, record, phone.PhoneID)
8284

8385
heartbeats, response, err := newAPIClient().Heartbeats.Index(ctx, &httpsms.HeartbeatIndexParams{
8486
Owner: phone.PhoneNumber,

0 commit comments

Comments
 (0)