From 020b00c353e6a24f66adedf1bd04f7868f47f8b0 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 3 Mar 2026 03:01:56 -0700 Subject: [PATCH] feat(http/androidsmsgateway): add webhook examples, JSON, CSV, and signature verification --- http/androidsmsgateway/csv_test.go | 339 ++++++++++++++++++ http/androidsmsgateway/go.mod | 2 + http/androidsmsgateway/go.sum | 2 + http/androidsmsgateway/signature.go | 22 ++ http/androidsmsgateway/webhooks.go | 325 ++++++++++++++--- http/androidsmsgateway/webhooks_test.go | 444 ++++++++++++++++++++++++ 6 files changed, 1089 insertions(+), 45 deletions(-) create mode 100644 http/androidsmsgateway/csv_test.go create mode 100644 http/androidsmsgateway/go.sum create mode 100644 http/androidsmsgateway/signature.go create mode 100644 http/androidsmsgateway/webhooks_test.go diff --git a/http/androidsmsgateway/csv_test.go b/http/androidsmsgateway/csv_test.go new file mode 100644 index 0000000..a2e378a --- /dev/null +++ b/http/androidsmsgateway/csv_test.go @@ -0,0 +1,339 @@ +package androidsmsgateway_test + +import ( + "strings" + "testing" + "time" + + "github.com/jszwec/csvutil" + "github.com/therootcompany/golib/http/androidsmsgateway" +) + +// csvLines marshals v as CSV and returns the header line and first data line. +func csvLines[T any](t *testing.T, v T) (header, row string) { + t.Helper() + b, err := csvutil.Marshal([]T{v}) + if err != nil { + t.Fatalf("csvutil.Marshal: %v", err) + } + lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") + if len(lines) < 2 { + t.Fatalf("expected at least 2 CSV lines, got %d:\n%s", len(lines), b) + } + return lines[0], lines[1] +} + +// csvParseOne unmarshals a header+row CSV pair into T. +func csvParseOne[T any](t *testing.T, header, row string) T { + t.Helper() + var out []T + if err := csvutil.Unmarshal([]byte(header+"\n"+row+"\n"), &out); err != nil { + t.Fatalf("csvutil.Unmarshal: %v", err) + } + if len(out) == 0 { + t.Fatal("csvutil.Unmarshal: no rows returned") + } + return out[0] +} + +func TestCSV_WebhookReceived(t *testing.T) { + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db0000018e937c815b", + "event": "sms:received", + "id": "Ey6ECgOkVVFjz3CL48B8C", + "payload": { + "messageId": "abc123", + "message": "Android is always a sweet treat!", + "phoneNumber":"+16505551212", + "simNumber": 1, + "receivedAt": "2024-06-22T15:46:11.000+07:00" + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookReceived) + if !ok { + t.Fatalf("expected *WebhookReceived, got %T", ev) + } + + const ( + wantHeader = "deviceId,event,id,message,messageId,phoneNumber,receivedAt,simNumber,webhookId" + wantRow = "ffffffffceb0b1db0000018e937c815b,sms:received,Ey6ECgOkVVFjz3CL48B8C,Android is always a sweet treat!,abc123,+16505551212,2024-06-22T15:46:11+07:00,1,LreFUt-Z3sSq0JufY9uWB" + ) + + header, row := csvLines(t, *got) + if header != wantHeader { + t.Errorf("header:\n got %q\n want %q", header, wantHeader) + } + if row != wantRow { + t.Errorf("row:\n got %q\n want %q", row, wantRow) + } + + // Round-trip: parse the known CSV and verify fields. + parsed := csvParseOne[androidsmsgateway.WebhookReceived](t, wantHeader, wantRow) + if parsed.DeviceID != got.DeviceID { + t.Errorf("DeviceID: got %q, want %q", parsed.DeviceID, got.DeviceID) + } + if parsed.Payload.Message != got.Payload.Message { + t.Errorf("Message: got %q, want %q", parsed.Payload.Message, got.Payload.Message) + } + if parsed.Payload.MessageID != got.Payload.MessageID { + t.Errorf("MessageID: got %q, want %q", parsed.Payload.MessageID, got.Payload.MessageID) + } + if parsed.Payload.PhoneNumber != got.Payload.PhoneNumber { + t.Errorf("PhoneNumber: got %q, want %q", parsed.Payload.PhoneNumber, got.Payload.PhoneNumber) + } + if parsed.Payload.SimNumber != got.Payload.SimNumber { + t.Errorf("SimNumber: got %d, want %d", parsed.Payload.SimNumber, got.Payload.SimNumber) + } + if !parsed.Payload.ReceivedAt.Equal(got.Payload.ReceivedAt) { + t.Errorf("ReceivedAt: got %v, want %v", parsed.Payload.ReceivedAt, got.Payload.ReceivedAt) + } +} + +func TestCSV_WebhookSent(t *testing.T) { + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db0000018e937c815b", + "event": "sms:sent", + "id": "Ey6ECgOkVVFjz3CL48B8C", + "payload": { + "messageId": "msg-456", + "sender": "+1234567890", + "recipient": "+9998887777", + "simNumber": 1, + "partsCount": 1, + "sentAt": "2026-02-18T02:05:00.000+07:00" + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookSent) + if !ok { + t.Fatalf("expected *WebhookSent, got %T", ev) + } + + const ( + wantHeader = "deviceId,event,id,messageId,partsCount,recipient,sender,simNumber,sentAt,webhookId" + wantRow = "ffffffffceb0b1db0000018e937c815b,sms:sent,Ey6ECgOkVVFjz3CL48B8C,msg-456,1,+9998887777,+1234567890,1,2026-02-18T02:05:00+07:00,LreFUt-Z3sSq0JufY9uWB" + ) + + header, row := csvLines(t, *got) + if header != wantHeader { + t.Errorf("header:\n got %q\n want %q", header, wantHeader) + } + if row != wantRow { + t.Errorf("row:\n got %q\n want %q", row, wantRow) + } + + // Round-trip. + parsed := csvParseOne[androidsmsgateway.WebhookSent](t, wantHeader, wantRow) + if parsed.Payload.MessageID != got.Payload.MessageID { + t.Errorf("MessageID: got %q, want %q", parsed.Payload.MessageID, got.Payload.MessageID) + } + if parsed.Payload.Sender != got.Payload.Sender { + t.Errorf("Sender: got %q, want %q", parsed.Payload.Sender, got.Payload.Sender) + } + if parsed.Payload.Recipient != got.Payload.Recipient { + t.Errorf("Recipient: got %q, want %q", parsed.Payload.Recipient, got.Payload.Recipient) + } + if parsed.Payload.PartsCount != got.Payload.PartsCount { + t.Errorf("PartsCount: got %d, want %d", parsed.Payload.PartsCount, got.Payload.PartsCount) + } + if !parsed.Payload.SentAt.Equal(got.Payload.SentAt) { + t.Errorf("SentAt: got %v, want %v", parsed.Payload.SentAt, got.Payload.SentAt) + } +} + +func TestCSV_WebhookDelivered(t *testing.T) { + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db0000018e937c815b", + "event": "sms:delivered", + "id": "Ey6ECgOkVVFjz3CL48B8C", + "payload": { + "messageId": "msg-789", + "sender": "+1234567890", + "recipient": "+9998887777", + "simNumber": 1, + "deliveredAt": "2026-02-18T02:10:00.000+07:00" + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookDelivered) + if !ok { + t.Fatalf("expected *WebhookDelivered, got %T", ev) + } + + const ( + wantHeader = "deviceId,event,id,deliveredAt,messageId,recipient,sender,simNumber,webhookId" + wantRow = "ffffffffceb0b1db0000018e937c815b,sms:delivered,Ey6ECgOkVVFjz3CL48B8C,2026-02-18T02:10:00+07:00,msg-789,+9998887777,+1234567890,1,LreFUt-Z3sSq0JufY9uWB" + ) + + header, row := csvLines(t, *got) + if header != wantHeader { + t.Errorf("header:\n got %q\n want %q", header, wantHeader) + } + if row != wantRow { + t.Errorf("row:\n got %q\n want %q", row, wantRow) + } + + // Round-trip. + parsed := csvParseOne[androidsmsgateway.WebhookDelivered](t, wantHeader, wantRow) + if parsed.Payload.MessageID != got.Payload.MessageID { + t.Errorf("MessageID: got %q, want %q", parsed.Payload.MessageID, got.Payload.MessageID) + } + if parsed.Payload.Sender != got.Payload.Sender { + t.Errorf("Sender: got %q, want %q", parsed.Payload.Sender, got.Payload.Sender) + } + if parsed.Payload.Recipient != got.Payload.Recipient { + t.Errorf("Recipient: got %q, want %q", parsed.Payload.Recipient, got.Payload.Recipient) + } + if !parsed.Payload.DeliveredAt.Equal(got.Payload.DeliveredAt) { + t.Errorf("DeliveredAt: got %v, want %v", parsed.Payload.DeliveredAt, got.Payload.DeliveredAt) + } +} + +func TestCSV_WebhookFailed(t *testing.T) { + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db0000018e937c815b", + "event": "sms:failed", + "id": "Ey6ECgOkVVFjz3CL48B8C", + "payload": { + "messageId": "msg-000", + "sender": "+1234567890", + "recipient": "+4445556666", + "simNumber": 3, + "failedAt": "2026-02-18T02:15:00.000+07:00", + "reason": "Network error" + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookFailed) + if !ok { + t.Fatalf("expected *WebhookFailed, got %T", ev) + } + + const ( + wantHeader = "deviceId,event,id,failedAt,messageId,reason,recipient,sender,simNumber,webhookId" + wantRow = "ffffffffceb0b1db0000018e937c815b,sms:failed,Ey6ECgOkVVFjz3CL48B8C,2026-02-18T02:15:00+07:00,msg-000,Network error,+4445556666,+1234567890,3,LreFUt-Z3sSq0JufY9uWB" + ) + + header, row := csvLines(t, *got) + if header != wantHeader { + t.Errorf("header:\n got %q\n want %q", header, wantHeader) + } + if row != wantRow { + t.Errorf("row:\n got %q\n want %q", row, wantRow) + } + + // Round-trip. + parsed := csvParseOne[androidsmsgateway.WebhookFailed](t, wantHeader, wantRow) + if parsed.Payload.MessageID != got.Payload.MessageID { + t.Errorf("MessageID: got %q, want %q", parsed.Payload.MessageID, got.Payload.MessageID) + } + if parsed.Payload.Reason != got.Payload.Reason { + t.Errorf("Reason: got %q, want %q", parsed.Payload.Reason, got.Payload.Reason) + } + if parsed.Payload.SimNumber != got.Payload.SimNumber { + t.Errorf("SimNumber: got %d, want %d", parsed.Payload.SimNumber, got.Payload.SimNumber) + } + if !parsed.Payload.FailedAt.Equal(got.Payload.FailedAt) { + t.Errorf("FailedAt: got %v, want %v", parsed.Payload.FailedAt, got.Payload.FailedAt) + } +} + +func TestCSV_WebhookPing(t *testing.T) { + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db00000192672f2204", + "event": "system:ping", + "id": "mjDoocQLCsOIDra_GthuI", + "payload": { + "health": { + "checks": { + "messages:failed": { + "description": "Failed messages for last hour", + "observedUnit": "messages", + "observedValue": 0, + "status": "pass" + }, + "connection:status": { + "description": "Internet connection status", + "observedUnit": "boolean", + "observedValue": 1, + "status": "pass" + }, + "connection:transport": { + "description": "Network transport type", + "observedUnit": "flags", + "observedValue": 4, + "status": "pass" + }, + "connection:cellular": { + "description": "Cellular network type", + "observedUnit": "index", + "observedValue": 0, + "status": "pass" + }, + "battery:level": { + "description": "Battery level in percent", + "observedUnit": "percent", + "observedValue": 94, + "status": "pass" + }, + "battery:charging": { + "description": "Is the phone charging?", + "observedUnit": "flags", + "observedValue": 4, + "status": "pass" + } + }, + "releaseId": 1, + "status": "pass", + "version": "1.0.0" + } + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookPing) + if !ok { + t.Fatalf("expected *WebhookPing, got %T", ev) + } + // PingedAt is set by the server from X-Timestamp; use a fixed value here. + got.PingedAt = time.Date(2026, 2, 23, 0, 1, 1, 0, time.UTC) + + const ( + wantHeader = "deviceId,event,id,battery_charging,battery_level,connection_cellular,connection_status,connection_transport,messages_failed,releaseId,status,version,webhookId,pingedAt" + wantRow = "ffffffffceb0b1db00000192672f2204,system:ping,mjDoocQLCsOIDra_GthuI,4,94,0,TRUE,4,0,1,pass,1.0.0,LreFUt-Z3sSq0JufY9uWB,2026-02-23T00:01:01Z" + ) + + header, row := csvLines(t, *got) + if header != wantHeader { + t.Errorf("header:\n got %q\n want %q", header, wantHeader) + } + if row != wantRow { + t.Errorf("row:\n got %q\n want %q", row, wantRow) + } + + // Round-trip: parse the known CSV and verify key fields. + // (BoolHealthCheck and HealthCheck are write-only in CSV — their text form is + // a single number, so parsing back gives only ObservedValue; that is enough to + // verify the round-trip.) + parsed := csvParseOne[androidsmsgateway.WebhookPing](t, wantHeader, wantRow) + if parsed.DeviceID != got.DeviceID { + t.Errorf("DeviceID: got %q, want %q", parsed.DeviceID, got.DeviceID) + } + if parsed.Payload.Health.Status != got.Payload.Health.Status { + t.Errorf("Health.Status: got %q, want %q", parsed.Payload.Health.Status, got.Payload.Health.Status) + } + if parsed.Payload.Health.Version != got.Payload.Health.Version { + t.Errorf("Health.Version: got %q, want %q", parsed.Payload.Health.Version, got.Payload.Health.Version) + } + if parsed.Payload.Health.ReleaseID != got.Payload.Health.ReleaseID { + t.Errorf("Health.ReleaseID: got %d, want %d", parsed.Payload.Health.ReleaseID, got.Payload.Health.ReleaseID) + } + if !parsed.PingedAt.Equal(got.PingedAt) { + t.Errorf("PingedAt: got %v, want %v", parsed.PingedAt, got.PingedAt) + } +} diff --git a/http/androidsmsgateway/go.mod b/http/androidsmsgateway/go.mod index c7bd44b..0c82548 100644 --- a/http/androidsmsgateway/go.mod +++ b/http/androidsmsgateway/go.mod @@ -1,3 +1,5 @@ module github.com/therootcompany/golib/http/androidsmsgateway go 1.24.6 + +require github.com/jszwec/csvutil v1.10.0 // indirect diff --git a/http/androidsmsgateway/go.sum b/http/androidsmsgateway/go.sum new file mode 100644 index 0000000..6a61a22 --- /dev/null +++ b/http/androidsmsgateway/go.sum @@ -0,0 +1,2 @@ +github.com/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI= +github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I= diff --git a/http/androidsmsgateway/signature.go b/http/androidsmsgateway/signature.go new file mode 100644 index 0000000..e3fa4b5 --- /dev/null +++ b/http/androidsmsgateway/signature.go @@ -0,0 +1,22 @@ +package androidsmsgateway + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +// Sign returns the hex-encoded HMAC-SHA256 signature for a webhook payload. +// The message is payload concatenated with timestamp (the X-Timestamp header value). +func Sign(secretKey, payload, timestamp string) string { + mac := hmac.New(sha256.New, []byte(secretKey)) + mac.Write([]byte(payload + timestamp)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// VerifySignature verifies the HMAC-SHA256 signature of a webhook payload. +// The message is payload concatenated with timestamp (the X-Timestamp header value). +// The signature is the hex-encoded HMAC-SHA256 of that message using secretKey. +func VerifySignature(secretKey, payload, timestamp, signature string) bool { + return hmac.Equal([]byte(Sign(secretKey, payload, timestamp)), []byte(signature)) +} diff --git a/http/androidsmsgateway/webhooks.go b/http/androidsmsgateway/webhooks.go index 2598006..7db4fd3 100644 --- a/http/androidsmsgateway/webhooks.go +++ b/http/androidsmsgateway/webhooks.go @@ -3,6 +3,7 @@ package androidsmsgateway import ( "encoding/json" "errors" + "strconv" "time" ) @@ -24,13 +25,13 @@ type Webhook struct { // WebhookSent represents a webhook notification for an SMS sent event. type WebhookSent struct { - DeviceID string `json:"deviceId"` - Event string `json:"event"` - ID string `json:"id"` - Payload WebhookSentPayload `json:"payload"` - WebhookID string `json:"webhookId"` - XSignature string `json:"x-signature"` - XTimestamp int64 `json:"x-timestamp"` + DeviceID string `json:"deviceId" csv:"deviceId"` + Event string `json:"event" csv:"event"` + ID string `json:"id" csv:"id"` + Payload WebhookSentPayload `json:"payload" csv:",inline"` + WebhookID string `json:"webhookId" csv:"webhookId"` + XSignature string `json:"x-signature" csv:"-"` + XTimestamp int64 `json:"x-timestamp" csv:"-"` } // GetEvent marks WebhookSent as part of the WebhookEvent interface. @@ -40,21 +41,23 @@ func (w *WebhookSent) GetEvent() string { // WebhookSentPayload contains details about the sent SMS. type WebhookSentPayload struct { - MessageID string `json:"messageId"` - PartsCount int `json:"partsCount"` - PhoneNumber string `json:"phoneNumber"` - SentAt time.Time `json:"sentAt"` + MessageID string `json:"messageId" csv:"messageId"` + PartsCount int `json:"partsCount" csv:"partsCount"` + Recipient string `json:"recipient" csv:"recipient"` + Sender string `json:"sender" csv:"sender"` + SimNumber int `json:"simNumber" csv:"simNumber"` + SentAt time.Time `json:"sentAt" csv:"sentAt"` } // WebhookDelivered represents a webhook notification for an SMS delivered event. type WebhookDelivered struct { - DeviceID string `json:"deviceId"` - Event string `json:"event"` - ID string `json:"id"` - Payload WebhookDeliveredPayload `json:"payload"` - WebhookID string `json:"webhookId"` - XSignature string `json:"x-signature"` - XTimestamp int64 `json:"x-timestamp"` + DeviceID string `json:"deviceId" csv:"deviceId"` + Event string `json:"event" csv:"event"` + ID string `json:"id" csv:"id"` + Payload WebhookDeliveredPayload `json:"payload" csv:",inline"` + WebhookID string `json:"webhookId" csv:"webhookId"` + XSignature string `json:"x-signature" csv:"-"` + XTimestamp int64 `json:"x-timestamp" csv:"-"` } // GetEvent marks WebhookDelivered as part of the WebhookEvent interface. @@ -64,20 +67,22 @@ func (w *WebhookDelivered) GetEvent() string { // WebhookDeliveredPayload contains details about the delivered SMS. type WebhookDeliveredPayload struct { - DeliveredAt time.Time `json:"deliveredAt"` - MessageID string `json:"messageId"` - PhoneNumber string `json:"phoneNumber"` + DeliveredAt time.Time `json:"deliveredAt" csv:"deliveredAt"` + MessageID string `json:"messageId" csv:"messageId"` + Recipient string `json:"recipient" csv:"recipient"` + Sender string `json:"sender" csv:"sender"` + SimNumber int `json:"simNumber" csv:"simNumber"` } // WebhookReceived represents a webhook notification for an SMS received event. type WebhookReceived struct { - DeviceID string `json:"deviceId"` - Event string `json:"event"` - ID string `json:"id"` - Payload WebhookReceivedPayload `json:"payload"` - WebhookID string `json:"webhookId"` - XSignature string `json:"x-signature"` - XTimestamp int64 `json:"x-timestamp"` + DeviceID string `json:"deviceId" csv:"deviceId"` + Event string `json:"event" csv:"event"` + ID string `json:"id" csv:"id"` + Payload WebhookReceivedPayload `json:"payload" csv:",inline"` + WebhookID string `json:"webhookId" csv:"webhookId"` + XSignature string `json:"x-signature" csv:"-"` + XTimestamp int64 `json:"x-timestamp" csv:"-"` } // GetEvent marks WebhookDelivered as part of the WebhookEvent interface. @@ -87,22 +92,104 @@ func (w *WebhookReceived) GetEvent() string { // WebhookReceivedPayload contains details about the received SMS. type WebhookReceivedPayload struct { - Message string `json:"message"` - MessageID string `json:"messageId"` - PhoneNumber string `json:"phoneNumber"` - ReceivedAt time.Time `json:"receivedAt"` - SimNumber int `json:"simNumber"` + Message string `json:"message" csv:"message"` + MessageID string `json:"messageId" csv:"messageId"` + PhoneNumber string `json:"phoneNumber" csv:"phoneNumber"` + ReceivedAt time.Time `json:"receivedAt" csv:"receivedAt"` + SimNumber int `json:"simNumber" csv:"simNumber"` +} + +// WebhookDataReceived represents a webhook notification for an sms:data-received event. +type WebhookDataReceived struct { + DeviceID string `json:"deviceId" csv:"deviceId"` + Event string `json:"event" csv:"event"` + ID string `json:"id" csv:"id"` + Payload WebhookDataReceivedPayload `json:"payload" csv:",inline"` + WebhookID string `json:"webhookId" csv:"webhookId"` + XSignature string `json:"x-signature" csv:"-"` + XTimestamp int64 `json:"x-timestamp" csv:"-"` +} + +// GetEvent marks WebhookDataReceived as part of the WebhookEvent interface. +func (w *WebhookDataReceived) GetEvent() string { + return w.Event +} + +// WebhookDataReceivedPayload contains details about the received binary SMS. +type WebhookDataReceivedPayload struct { + Data string `json:"data" csv:"data"` + MessageID string `json:"messageId" csv:"messageId"` + ReceivedAt time.Time `json:"receivedAt" csv:"receivedAt"` + Recipient string `json:"recipient" csv:"recipient"` + Sender string `json:"sender" csv:"sender"` + SimNumber int `json:"simNumber" csv:"simNumber"` +} + +// WebhookMMSReceived represents a webhook notification for an mms:received event. +type WebhookMMSReceived struct { + DeviceID string `json:"deviceId" csv:"deviceId"` + Event string `json:"event" csv:"event"` + ID string `json:"id" csv:"id"` + Payload WebhookMMSReceivedPayload `json:"payload" csv:",inline"` + WebhookID string `json:"webhookId" csv:"webhookId"` + XSignature string `json:"x-signature" csv:"-"` + XTimestamp int64 `json:"x-timestamp" csv:"-"` +} + +// GetEvent marks WebhookMMSReceived as part of the WebhookEvent interface. +func (w *WebhookMMSReceived) GetEvent() string { + return w.Event +} + +// WebhookMMSReceivedPayload contains details about the received MMS. +type WebhookMMSReceivedPayload struct { + ContentClass string `json:"contentClass" csv:"contentClass"` + MessageID string `json:"messageId" csv:"messageId"` + ReceivedAt time.Time `json:"receivedAt" csv:"receivedAt"` + Recipient string `json:"recipient" csv:"recipient"` + Sender string `json:"sender" csv:"sender"` + SimNumber int `json:"simNumber" csv:"simNumber"` + Size int `json:"size" csv:"size"` + Subject string `json:"subject" csv:"subject"` + TransactionID string `json:"transactionId" csv:"transactionId"` +} + +// WebhookFailed represents a webhook notification for an sms:failed event. +type WebhookFailed struct { + DeviceID string `json:"deviceId" csv:"deviceId"` + Event string `json:"event" csv:"event"` + ID string `json:"id" csv:"id"` + Payload WebhookFailedPayload `json:"payload" csv:",inline"` + WebhookID string `json:"webhookId" csv:"webhookId"` + XSignature string `json:"x-signature" csv:"-"` + XTimestamp int64 `json:"x-timestamp" csv:"-"` +} + +// GetEvent marks WebhookFailed as part of the WebhookEvent interface. +func (w *WebhookFailed) GetEvent() string { + return w.Event +} + +// WebhookFailedPayload contains details about the failed SMS. +type WebhookFailedPayload struct { + FailedAt time.Time `json:"failedAt" csv:"failedAt"` + MessageID string `json:"messageId" csv:"messageId"` + Reason string `json:"reason" csv:"reason"` + Recipient string `json:"recipient" csv:"recipient"` + Sender string `json:"sender" csv:"sender"` + SimNumber int `json:"simNumber" csv:"simNumber"` } // WebhookPing represents a system:ping webhook event. type WebhookPing struct { - DeviceID string `json:"deviceId"` - Event string `json:"event"` - ID string `json:"id"` - Payload WebhookPingPayload `json:"payload"` - WebhookID string `json:"webhookId"` - XSignature string `json:"x-signature"` - XTimestamp int64 `json:"x-timestamp"` + DeviceID string `json:"deviceId" csv:"deviceId"` + Event string `json:"event" csv:"event"` + ID string `json:"id" csv:"id"` + Payload WebhookPingPayload `json:"payload" csv:",inline"` + WebhookID string `json:"webhookId" csv:"webhookId"` + PingedAt time.Time `json:"pingedAt,omitempty" csv:"pingedAt"` + XSignature string `json:"x-signature" csv:"-"` + XTimestamp int64 `json:"x-timestamp" csv:"-"` } // GetEvent marks WebhookPing as part of the WebhookEvent interface. @@ -112,18 +199,33 @@ func (w *WebhookPing) GetEvent() string { // WebhookPingPayload contains the health data reported by a system:ping event. type WebhookPingPayload struct { - Health DeviceHealth `json:"health"` + Health DeviceHealth `json:"health" csv:",inline"` } // DeviceHealth is the top-level health object inside a system:ping payload. +// Named fields use colon json tags matching the API key names inside "checks". type DeviceHealth struct { - Checks map[string]HealthCheck `json:"checks"` - ReleaseID int `json:"releaseId"` - Status string `json:"status"` - Version string `json:"version"` + Checks DeviceChecks `json:"checks" csv:",inline"` + ReleaseID int `json:"releaseId" csv:"releaseId"` + Status string `json:"status" csv:"status"` + Version string `json:"version" csv:"version"` +} + +// DeviceChecks holds the individual health checks reported by the device. +// Go field names are camelCase; json tags carry the colon-delimited API key names. +// csv tags mirror the documentation key names with underscores instead of colons. +type DeviceChecks struct { + BatteryCharging HealthCheck `json:"battery:charging" csv:"battery_charging"` + BatteryLevel HealthCheck `json:"battery:level" csv:"battery_level"` + ConnectionCellular HealthCheck `json:"connection:cellular" csv:"connection_cellular"` + ConnectionStatus BoolHealthCheck `json:"connection:status" csv:"connection_status"` + ConnectionTransport HealthCheck `json:"connection:transport" csv:"connection_transport"` + MessagesFailed HealthCheck `json:"messages:failed" csv:"messages_failed"` } // HealthCheck represents a single named health check result. +// MarshalText returns the observed value as a number for CSV encoding. +// MarshalJSON overrides MarshalText so JSON output is always the full object. type HealthCheck struct { Description string `json:"description"` ObservedUnit string `json:"observedUnit"` @@ -131,9 +233,106 @@ type HealthCheck struct { Status string `json:"status"` } +// MarshalText implements encoding.TextMarshaler for CSV serialisation. +// Returns the observed numeric value so each check becomes a single CSV column. +func (c HealthCheck) MarshalText() ([]byte, error) { + return []byte(strconv.FormatFloat(c.ObservedValue, 'f', -1, 64)), nil +} + +// MarshalJSON prevents MarshalText from being used during JSON serialisation, +// ensuring HealthCheck is always encoded as a full JSON object. +func (c HealthCheck) MarshalJSON() ([]byte, error) { + type alias HealthCheck + return json.Marshal(alias(c)) +} + +// UnmarshalText implements encoding.TextUnmarshaler for CSV deserialisation. +// Parses the numeric observed value produced by MarshalText. +func (c *HealthCheck) UnmarshalText(b []byte) error { + v, err := strconv.ParseFloat(string(b), 64) + if err != nil { + return err + } + c.ObservedValue = v + return nil +} + +// UnmarshalJSON prevents UnmarshalText from being used during JSON deserialisation, +// ensuring HealthCheck is always decoded as a full JSON object. +func (c *HealthCheck) UnmarshalJSON(b []byte) error { + type alias HealthCheck + var a alias + if err := json.Unmarshal(b, &a); err != nil { + return err + } + *c = HealthCheck(a) + return nil +} + +// BoolHealthCheck is a HealthCheck whose CSV representation is TRUE or FALSE +// based on whether ObservedValue is non-zero. Used for connection:status. +type BoolHealthCheck HealthCheck + +// MarshalText implements encoding.TextMarshaler for CSV serialisation. +func (c BoolHealthCheck) MarshalText() ([]byte, error) { + if c.ObservedValue != 0 { + return []byte("TRUE"), nil + } + return []byte("FALSE"), nil +} + +// MarshalJSON prevents MarshalText from being used during JSON serialisation, +// ensuring BoolHealthCheck is always encoded as a full JSON object. +func (c BoolHealthCheck) MarshalJSON() ([]byte, error) { + type alias BoolHealthCheck + return json.Marshal(alias(c)) +} + +// UnmarshalText implements encoding.TextUnmarshaler for CSV deserialisation. +// Parses "TRUE" (→ 1) or "FALSE" (→ 0) back into ObservedValue. +func (c *BoolHealthCheck) UnmarshalText(b []byte) error { + switch string(b) { + case "TRUE": + c.ObservedValue = 1 + case "FALSE": + c.ObservedValue = 0 + default: + v, err := strconv.ParseFloat(string(b), 64) + if err != nil { + return err + } + c.ObservedValue = v + } + return nil +} + +// UnmarshalJSON prevents UnmarshalText from being used during JSON deserialisation, +// ensuring BoolHealthCheck is always decoded as a full JSON object. +func (c *BoolHealthCheck) UnmarshalJSON(b []byte) error { + type alias BoolHealthCheck + var a alias + if err := json.Unmarshal(b, &a); err != nil { + return err + } + *c = BoolHealthCheck(a) + return nil +} + // Decode decodes the raw Payload based on the Event field and returns the appropriate WebhookEvent. func Decode(webhook *Webhook) (WebhookEvent, error) { switch webhook.Event { + case "system:ping": + var ping WebhookPing + ping.DeviceID = webhook.DeviceID + ping.Event = webhook.Event + ping.ID = webhook.ID + ping.WebhookID = webhook.WebhookID + ping.XSignature = webhook.XSignature + ping.XTimestamp = webhook.XTimestamp + if err := json.Unmarshal(webhook.Payload, &ping.Payload); err != nil { + return nil, errors.New("failed to decode system:ping payload: " + err.Error()) + } + return &ping, nil case "sms:sent": var sent WebhookSent sent.DeviceID = webhook.DeviceID @@ -158,6 +357,42 @@ func Decode(webhook *Webhook) (WebhookEvent, error) { return nil, errors.New("failed to decode sms:delivered payload: " + err.Error()) } return &delivered, nil + case "sms:data-received": + var ev WebhookDataReceived + ev.DeviceID = webhook.DeviceID + ev.Event = webhook.Event + ev.ID = webhook.ID + ev.WebhookID = webhook.WebhookID + ev.XSignature = webhook.XSignature + ev.XTimestamp = webhook.XTimestamp + if err := json.Unmarshal(webhook.Payload, &ev.Payload); err != nil { + return nil, errors.New("failed to decode sms:data-received payload: " + err.Error()) + } + return &ev, nil + case "mms:received": + var ev WebhookMMSReceived + ev.DeviceID = webhook.DeviceID + ev.Event = webhook.Event + ev.ID = webhook.ID + ev.WebhookID = webhook.WebhookID + ev.XSignature = webhook.XSignature + ev.XTimestamp = webhook.XTimestamp + if err := json.Unmarshal(webhook.Payload, &ev.Payload); err != nil { + return nil, errors.New("failed to decode mms:received payload: " + err.Error()) + } + return &ev, nil + case "sms:failed": + var ev WebhookFailed + ev.DeviceID = webhook.DeviceID + ev.Event = webhook.Event + ev.ID = webhook.ID + ev.WebhookID = webhook.WebhookID + ev.XSignature = webhook.XSignature + ev.XTimestamp = webhook.XTimestamp + if err := json.Unmarshal(webhook.Payload, &ev.Payload); err != nil { + return nil, errors.New("failed to decode sms:failed payload: " + err.Error()) + } + return &ev, nil case "sms:received": var received WebhookReceived received.DeviceID = webhook.DeviceID diff --git a/http/androidsmsgateway/webhooks_test.go b/http/androidsmsgateway/webhooks_test.go new file mode 100644 index 0000000..532bc7c --- /dev/null +++ b/http/androidsmsgateway/webhooks_test.go @@ -0,0 +1,444 @@ +package androidsmsgateway_test + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "testing" + "time" + + "github.com/therootcompany/golib/http/androidsmsgateway" +) + +// decodeRaw unmarshals raw JSON into a Webhook then calls Decode. +func decodeRaw(t *testing.T, raw []byte) androidsmsgateway.WebhookEvent { + t.Helper() + var wh androidsmsgateway.Webhook + if err := json.Unmarshal(raw, &wh); err != nil { + t.Fatalf("unmarshal Webhook: %v", err) + } + ev, err := androidsmsgateway.Decode(&wh) + if err != nil { + t.Fatalf("Decode: %v", err) + } + return ev +} + +func TestDecode_SMSReceived(t *testing.T) { + // Verbatim example from the real gateway (includes x-signature and x-timestamp). + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db0000018e937c815b", + "event": "sms:received", + "id": "Ey6ECgOkVVFjz3CL48B8C", + "payload": { + "messageId": "abc123", + "message": "Android is always a sweet treat!", + "phoneNumber":"+16505551212", + "simNumber": 1, + "receivedAt": "2024-06-22T15:46:11.000+07:00" + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookReceived) + if !ok { + t.Fatalf("expected *WebhookReceived, got %T", ev) + } + if got.Event != "sms:received" { + t.Errorf("Event = %q, want sms:received", got.Event) + } + if got.DeviceID != "ffffffffceb0b1db0000018e937c815b" { + t.Errorf("DeviceID = %q", got.DeviceID) + } + if got.WebhookID != "LreFUt-Z3sSq0JufY9uWB" { + t.Errorf("WebhookID = %q", got.WebhookID) + } + if got.Payload.MessageID != "abc123" { + t.Errorf("MessageID = %q, want abc123", got.Payload.MessageID) + } + if got.Payload.Message != "Android is always a sweet treat!" { + t.Errorf("Message = %q, want \"Android is always a sweet treat!\"", got.Payload.Message) + } + if got.Payload.PhoneNumber != "+16505551212" { + t.Errorf("PhoneNumber = %q, want +16505551212", got.Payload.PhoneNumber) + } + if got.Payload.SimNumber != 1 { + t.Errorf("SimNumber = %d, want 1", got.Payload.SimNumber) + } + want, _ := time.Parse(time.RFC3339Nano, "2024-06-22T15:46:11.000+07:00") + if !got.Payload.ReceivedAt.Equal(want) { + t.Errorf("ReceivedAt = %v, want %v", got.Payload.ReceivedAt, want) + } +} + +func TestDecode_SMSDataReceived(t *testing.T) { + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db0000018e937c815b", + "event": "sms:data-received", + "id": "Ey6ECgOkVVFjz3CL48B8C", + "payload": { + "messageId": "abc123", + "data": "SGVsbG8gRGF0YSBXb3JsZCE=", + "sender": "6505551212", + "recipient": "+1234567890", + "simNumber": 1, + "receivedAt": "2024-06-22T15:46:11.000+07:00" + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookDataReceived) + if !ok { + t.Fatalf("expected *WebhookDataReceived, got %T", ev) + } + if got.Event != "sms:data-received" { + t.Errorf("Event = %q, want sms:data-received", got.Event) + } + if got.Payload.MessageID != "abc123" { + t.Errorf("MessageID = %q, want abc123", got.Payload.MessageID) + } + if got.Payload.Data != "SGVsbG8gRGF0YSBXb3JsZCE=" { + t.Errorf("Data = %q, want SGVsbG8gRGF0YSBXb3JsZCE=", got.Payload.Data) + } + if got.Payload.Sender != "6505551212" { + t.Errorf("Sender = %q, want 6505551212", got.Payload.Sender) + } + if got.Payload.Recipient != "+1234567890" { + t.Errorf("Recipient = %q, want +1234567890", got.Payload.Recipient) + } + if got.Payload.SimNumber != 1 { + t.Errorf("SimNumber = %d, want 1", got.Payload.SimNumber) + } + want, _ := time.Parse(time.RFC3339Nano, "2024-06-22T15:46:11.000+07:00") + if !got.Payload.ReceivedAt.Equal(want) { + t.Errorf("ReceivedAt = %v, want %v", got.Payload.ReceivedAt, want) + } +} + +func TestDecode_MMSReceived(t *testing.T) { + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db0000018e937c815b", + "event": "mms:received", + "id": "Ey6ECgOkVVFjz3CL48B8C", + "payload": { + "messageId": "mms_12345abcde", + "sender": "6505551212", + "recipient": "+1234567890", + "simNumber": 1, + "transactionId": "T1234567890ABC", + "subject": "Photo attachment", + "size": 125684, + "contentClass": "IMAGE_BASIC", + "receivedAt": "2025-08-23T05:15:30.000+07:00" + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookMMSReceived) + if !ok { + t.Fatalf("expected *WebhookMMSReceived, got %T", ev) + } + if got.Event != "mms:received" { + t.Errorf("Event = %q, want mms:received", got.Event) + } + if got.Payload.MessageID != "mms_12345abcde" { + t.Errorf("MessageID = %q, want mms_12345abcde", got.Payload.MessageID) + } + if got.Payload.Sender != "6505551212" { + t.Errorf("Sender = %q, want 6505551212", got.Payload.Sender) + } + if got.Payload.Recipient != "+1234567890" { + t.Errorf("Recipient = %q, want +1234567890", got.Payload.Recipient) + } + if got.Payload.SimNumber != 1 { + t.Errorf("SimNumber = %d, want 1", got.Payload.SimNumber) + } + if got.Payload.TransactionID != "T1234567890ABC" { + t.Errorf("TransactionID = %q, want T1234567890ABC", got.Payload.TransactionID) + } + if got.Payload.Subject != "Photo attachment" { + t.Errorf("Subject = %q, want Photo attachment", got.Payload.Subject) + } + if got.Payload.Size != 125_684 { + t.Errorf("Size = %d, want 125684", got.Payload.Size) + } + if got.Payload.ContentClass != "IMAGE_BASIC" { + t.Errorf("ContentClass = %q, want IMAGE_BASIC", got.Payload.ContentClass) + } + want, _ := time.Parse(time.RFC3339Nano, "2025-08-23T05:15:30.000+07:00") + if !got.Payload.ReceivedAt.Equal(want) { + t.Errorf("ReceivedAt = %v, want %v", got.Payload.ReceivedAt, want) + } +} + +func TestDecode_SMSSent(t *testing.T) { + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db0000018e937c815b", + "event": "sms:sent", + "id": "Ey6ECgOkVVFjz3CL48B8C", + "payload": { + "messageId": "msg-456", + "sender": "+1234567890", + "recipient": "+9998887777", + "simNumber": 1, + "partsCount": 1, + "sentAt": "2026-02-18T02:05:00.000+07:00" + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookSent) + if !ok { + t.Fatalf("expected *WebhookSent, got %T", ev) + } + if got.Event != "sms:sent" { + t.Errorf("Event = %q, want sms:sent", got.Event) + } + if got.Payload.MessageID != "msg-456" { + t.Errorf("MessageID = %q, want msg-456", got.Payload.MessageID) + } + if got.Payload.Sender != "+1234567890" { + t.Errorf("Sender = %q, want +1234567890", got.Payload.Sender) + } + if got.Payload.Recipient != "+9998887777" { + t.Errorf("Recipient = %q, want +9998887777", got.Payload.Recipient) + } + if got.Payload.SimNumber != 1 { + t.Errorf("SimNumber = %d, want 1", got.Payload.SimNumber) + } + if got.Payload.PartsCount != 1 { + t.Errorf("PartsCount = %d, want 1", got.Payload.PartsCount) + } + want, _ := time.Parse(time.RFC3339Nano, "2026-02-18T02:05:00.000+07:00") + if !got.Payload.SentAt.Equal(want) { + t.Errorf("SentAt = %v, want %v", got.Payload.SentAt, want) + } +} + +func TestDecode_SMSDelivered(t *testing.T) { + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db0000018e937c815b", + "event": "sms:delivered", + "id": "Ey6ECgOkVVFjz3CL48B8C", + "payload": { + "messageId": "msg-789", + "sender": "+1234567890", + "recipient": "+9998887777", + "simNumber": 1, + "deliveredAt": "2026-02-18T02:10:00.000+07:00" + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookDelivered) + if !ok { + t.Fatalf("expected *WebhookDelivered, got %T", ev) + } + if got.Event != "sms:delivered" { + t.Errorf("Event = %q, want sms:delivered", got.Event) + } + if got.Payload.MessageID != "msg-789" { + t.Errorf("MessageID = %q, want msg-789", got.Payload.MessageID) + } + if got.Payload.Sender != "+1234567890" { + t.Errorf("Sender = %q, want +1234567890", got.Payload.Sender) + } + if got.Payload.Recipient != "+9998887777" { + t.Errorf("Recipient = %q, want +9998887777", got.Payload.Recipient) + } + if got.Payload.SimNumber != 1 { + t.Errorf("SimNumber = %d, want 1", got.Payload.SimNumber) + } + want, _ := time.Parse(time.RFC3339Nano, "2026-02-18T02:10:00.000+07:00") + if !got.Payload.DeliveredAt.Equal(want) { + t.Errorf("DeliveredAt = %v, want %v", got.Payload.DeliveredAt, want) + } +} + +func TestDecode_SMSFailed(t *testing.T) { + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db0000018e937c815b", + "event": "sms:failed", + "id": "Ey6ECgOkVVFjz3CL48B8C", + "payload": { + "messageId": "msg-000", + "sender": "+1234567890", + "recipient": "+4445556666", + "simNumber": 3, + "failedAt": "2026-02-18T02:15:00.000+07:00", + "reason": "Network error" + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookFailed) + if !ok { + t.Fatalf("expected *WebhookFailed, got %T", ev) + } + if got.Event != "sms:failed" { + t.Errorf("Event = %q, want sms:failed", got.Event) + } + if got.Payload.MessageID != "msg-000" { + t.Errorf("MessageID = %q, want msg-000", got.Payload.MessageID) + } + if got.Payload.Sender != "+1234567890" { + t.Errorf("Sender = %q, want +1234567890", got.Payload.Sender) + } + if got.Payload.Recipient != "+4445556666" { + t.Errorf("Recipient = %q, want +4445556666", got.Payload.Recipient) + } + if got.Payload.SimNumber != 3 { + t.Errorf("SimNumber = %d, want 3", got.Payload.SimNumber) + } + if got.Payload.Reason != "Network error" { + t.Errorf("Reason = %q, want Network error", got.Payload.Reason) + } + want, _ := time.Parse(time.RFC3339Nano, "2026-02-18T02:15:00.000+07:00") + if !got.Payload.FailedAt.Equal(want) { + t.Errorf("FailedAt = %v, want %v", got.Payload.FailedAt, want) + } +} + +func TestDecode_SystemPing(t *testing.T) { + raw := []byte(`{ + "deviceId": "ffffffffceb0b1db00000192672f2204", + "event": "system:ping", + "id": "mjDoocQLCsOIDra_GthuI", + "payload": { + "health": { + "checks": { + "messages:failed": { + "description": "Failed messages for last hour", + "observedUnit": "messages", + "observedValue": 0, + "status": "pass" + }, + "connection:status": { + "description": "Internet connection status", + "observedUnit": "boolean", + "observedValue": 1, + "status": "pass" + }, + "connection:transport": { + "description": "Network transport type", + "observedUnit": "flags", + "observedValue": 4, + "status": "pass" + }, + "connection:cellular": { + "description": "Cellular network type", + "observedUnit": "index", + "observedValue": 0, + "status": "pass" + }, + "battery:level": { + "description": "Battery level in percent", + "observedUnit": "percent", + "observedValue": 94, + "status": "pass" + }, + "battery:charging": { + "description": "Is the phone charging?", + "observedUnit": "flags", + "observedValue": 4, + "status": "pass" + } + }, + "releaseId": 1, + "status": "pass", + "version": "1.0.0" + } + }, + "webhookId": "LreFUt-Z3sSq0JufY9uWB" + }`) + ev := decodeRaw(t, raw) + got, ok := ev.(*androidsmsgateway.WebhookPing) + if !ok { + t.Fatalf("expected *WebhookPing, got %T", ev) + } + if got.Event != "system:ping" { + t.Errorf("Event = %q, want system:ping", got.Event) + } + h := got.Payload.Health + if h.Status != "pass" { + t.Errorf("Health.Status = %q, want pass", h.Status) + } + if h.Version != "1.0.0" { + t.Errorf("Health.Version = %q, want 1.0.0", h.Version) + } + if h.ReleaseID != 1 { + t.Errorf("Health.ReleaseID = %d, want 1", h.ReleaseID) + } + c := h.Checks + if c.BatteryLevel.ObservedValue != 94 { + t.Errorf("BatteryLevel.ObservedValue = %v, want 94", c.BatteryLevel.ObservedValue) + } + if c.BatteryLevel.Status != "pass" { + t.Errorf("BatteryLevel.Status = %q, want pass", c.BatteryLevel.Status) + } + if c.BatteryCharging.ObservedValue != 4 { + t.Errorf("BatteryCharging.ObservedValue = %v, want 4", c.BatteryCharging.ObservedValue) + } + if c.ConnectionStatus.ObservedValue != 1 { + t.Errorf("ConnectionStatus.ObservedValue = %v, want 1", c.ConnectionStatus.ObservedValue) + } + if c.ConnectionTransport.ObservedValue != 4 { + t.Errorf("ConnectionTransport.ObservedValue = %v, want 4", c.ConnectionTransport.ObservedValue) + } + if c.ConnectionCellular.ObservedValue != 0 { + t.Errorf("ConnectionCellular.ObservedValue = %v, want 0", c.ConnectionCellular.ObservedValue) + } + if c.MessagesFailed.ObservedValue != 0 { + t.Errorf("MessagesFailed.ObservedValue = %v, want 0", c.MessagesFailed.ObservedValue) + } +} + +func TestDecode_UnknownEvent(t *testing.T) { + raw := []byte(`{"deviceId":"dev1","event":"unknown:event","id":"id1","payload":{},"webhookId":"wh1"}`) + var wh androidsmsgateway.Webhook + if err := json.Unmarshal(raw, &wh); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, err := androidsmsgateway.Decode(&wh); err == nil { + t.Fatal("expected error for unknown event, got nil") + } +} + +func TestSign(t *testing.T) { + const ( + secret = "mysecretkey" + payload = `{"event":"sms:received"}` + timestamp = "1700000000" + ) + + // Independently compute the expected signature. + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(payload + timestamp)) + want := hex.EncodeToString(mac.Sum(nil)) + + got := androidsmsgateway.Sign(secret, payload, timestamp) + if got != want { + t.Errorf("Sign = %q, want %q", got, want) + } +} + +func TestVerifySignature(t *testing.T) { + const ( + secret = "mysecretkey" + payload = `{"event":"sms:received"}` + timestamp = "1700000000" + ) + + sig := androidsmsgateway.Sign(secret, payload, timestamp) + + if !androidsmsgateway.VerifySignature(secret, payload, timestamp, sig) { + t.Error("VerifySignature returned false for valid signature") + } + if androidsmsgateway.VerifySignature(secret, payload, timestamp, "badsignature") { + t.Error("VerifySignature returned true for invalid signature") + } + if androidsmsgateway.VerifySignature("wrongkey", payload, timestamp, sig) { + t.Error("VerifySignature returned true for wrong key") + } +}