From 97e7b9d31962d63a3147222b292c9ce5923693c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:37:57 +0000 Subject: [PATCH] feat: add VerifySignature + webhook decode/signature tests Co-authored-by: coolaj86 <122831+coolaj86@users.noreply.github.com> --- http/androidsmsgateway/signature.go | 18 ++ http/androidsmsgateway/webhooks_test.go | 276 ++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 http/androidsmsgateway/signature.go create mode 100644 http/androidsmsgateway/webhooks_test.go diff --git a/http/androidsmsgateway/signature.go b/http/androidsmsgateway/signature.go new file mode 100644 index 0000000..cabdef6 --- /dev/null +++ b/http/androidsmsgateway/signature.go @@ -0,0 +1,18 @@ +package androidsmsgateway + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +// 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 { + message := payload + timestamp + mac := hmac.New(sha256.New, []byte(secretKey)) + mac.Write([]byte(message)) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(signature)) +} diff --git a/http/androidsmsgateway/webhooks_test.go b/http/androidsmsgateway/webhooks_test.go new file mode 100644 index 0000000..db88a77 --- /dev/null +++ b/http/androidsmsgateway/webhooks_test.go @@ -0,0 +1,276 @@ +package androidsmsgateway_test + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "testing" + "time" + + "github.com/therootcompany/golib/http/androidsmsgateway" +) + +// mustTime parses an RFC3339Nano timestamp and panics on error. +func mustTime(s string) time.Time { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + panic(err) + } + return t +} + +// buildRaw marshals a Webhook JSON body from individual fields. +func buildRaw(t *testing.T, deviceID, event, id, webhookID string, payload any) []byte { + t.Helper() + rawPayload, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + w := struct { + DeviceID string `json:"deviceId"` + Event string `json:"event"` + ID string `json:"id"` + Payload json.RawMessage `json:"payload"` + WebhookID string `json:"webhookId"` + }{ + DeviceID: deviceID, + Event: event, + ID: id, + Payload: rawPayload, + WebhookID: webhookID, + } + b, err := json.Marshal(w) + if err != nil { + t.Fatalf("marshal webhook: %v", err) + } + return b +} + +// 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) { + raw := buildRaw(t, + "ffffffffceb0b1db0000018e937c815b", "sms:received", + "Ey6ECgOkVVFjz3CL48B8C", "LreFUt-Z3sSq0JufY9uWB", + map[string]any{ + "messageId": "abc123", + "message": "Android is always a sweet treat!", + "phoneNumber": "6505551212", + "simNumber": 1, + "receivedAt": "2024-06-22T15:46:11.000+07:00", + }, + ) + 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.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", got.Payload.Message) + } + if got.Payload.PhoneNumber != "6505551212" { + t.Errorf("PhoneNumber = %q, want 6505551212", got.Payload.PhoneNumber) + } + if got.Payload.SimNumber != 1 { + t.Errorf("SimNumber = %d, want 1", got.Payload.SimNumber) + } + want := mustTime("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_SMSSent(t *testing.T) { + raw := buildRaw(t, + "ffffffffceb0b1db0000018e937c815b", "sms:sent", + "Ey6ECgOkVVFjz3CL48B8C", "LreFUt-Z3sSq0JufY9uWB", + map[string]any{ + "messageId": "msg-456", + "phoneNumber": "+9998887777", + "simNumber": 1, + "partsCount": 1, + "sentAt": "2026-02-18T02:05:00.000+07:00", + }, + ) + 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.PhoneNumber != "+9998887777" { + t.Errorf("PhoneNumber = %q, want +9998887777", got.Payload.PhoneNumber) + } + if got.Payload.PartsCount != 1 { + t.Errorf("PartsCount = %d, want 1", got.Payload.PartsCount) + } + want := mustTime("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 := buildRaw(t, + "ffffffffceb0b1db0000018e937c815b", "sms:delivered", + "Ey6ECgOkVVFjz3CL48B8C", "LreFUt-Z3sSq0JufY9uWB", + map[string]any{ + "messageId": "msg-789", + "phoneNumber": "+9998887777", + "deliveredAt": "2026-02-18T02:10:00.000+07:00", + }, + ) + 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) + } + want := mustTime("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_SystemPing(t *testing.T) { + raw := buildRaw(t, + "ffffffffceb0b1db00000192672f2204", "system:ping", + "mjDoocQLCsOIDra_GthuI", "LreFUt-Z3sSq0JufY9uWB", + map[string]any{ + "health": map[string]any{ + "checks": map[string]any{ + "messages:failed": map[string]any{ + "description": "Failed messages for last hour", + "observedUnit": "messages", "observedValue": 0, "status": "pass", + }, + "connection:status": map[string]any{ + "description": "Internet connection status", + "observedUnit": "boolean", "observedValue": 1, "status": "pass", + }, + "connection:transport": map[string]any{ + "description": "Network transport type", + "observedUnit": "flags", "observedValue": 4, "status": "pass", + }, + "connection:cellular": map[string]any{ + "description": "Cellular network type", + "observedUnit": "index", "observedValue": 0, "status": "pass", + }, + "battery:level": map[string]any{ + "description": "Battery level in percent", + "observedUnit": "percent", "observedValue": 94, "status": "pass", + }, + "battery:charging": map[string]any{ + "description": "Is the phone charging?", + "observedUnit": "flags", "observedValue": 4, "status": "pass", + }, + }, + "releaseId": 1, + "status": "pass", + "version": "1.0.0", + }, + }, + ) + 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 := buildRaw(t, "dev1", "unknown:event", "id1", "wh1", map[string]any{}) + 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 TestVerifySignature(t *testing.T) { + const ( + secret = "mysecretkey" + payload = `{"event":"sms:received"}` + timestamp = "1700000000" + ) + + // Compute the expected signature. + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(payload + timestamp)) + sig := hex.EncodeToString(mac.Sum(nil)) + + 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") + } +}