mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-13 12:27:59 +00:00
feat(http/androidsmsgateway): add webhook examples, JSON, CSV, and signature verification
This commit is contained in:
parent
66dde73bd4
commit
020b00c353
339
http/androidsmsgateway/csv_test.go
Normal file
339
http/androidsmsgateway/csv_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
module github.com/therootcompany/golib/http/androidsmsgateway
|
module github.com/therootcompany/golib/http/androidsmsgateway
|
||||||
|
|
||||||
go 1.24.6
|
go 1.24.6
|
||||||
|
|
||||||
|
require github.com/jszwec/csvutil v1.10.0 // indirect
|
||||||
|
|||||||
2
http/androidsmsgateway/go.sum
Normal file
2
http/androidsmsgateway/go.sum
Normal file
@ -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=
|
||||||
22
http/androidsmsgateway/signature.go
Normal file
22
http/androidsmsgateway/signature.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package androidsmsgateway
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -24,13 +25,13 @@ type Webhook struct {
|
|||||||
|
|
||||||
// WebhookSent represents a webhook notification for an SMS sent event.
|
// WebhookSent represents a webhook notification for an SMS sent event.
|
||||||
type WebhookSent struct {
|
type WebhookSent struct {
|
||||||
DeviceID string `json:"deviceId"`
|
DeviceID string `json:"deviceId" csv:"deviceId"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event" csv:"event"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id" csv:"id"`
|
||||||
Payload WebhookSentPayload `json:"payload"`
|
Payload WebhookSentPayload `json:"payload" csv:",inline"`
|
||||||
WebhookID string `json:"webhookId"`
|
WebhookID string `json:"webhookId" csv:"webhookId"`
|
||||||
XSignature string `json:"x-signature"`
|
XSignature string `json:"x-signature" csv:"-"`
|
||||||
XTimestamp int64 `json:"x-timestamp"`
|
XTimestamp int64 `json:"x-timestamp" csv:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEvent marks WebhookSent as part of the WebhookEvent interface.
|
// 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.
|
// WebhookSentPayload contains details about the sent SMS.
|
||||||
type WebhookSentPayload struct {
|
type WebhookSentPayload struct {
|
||||||
MessageID string `json:"messageId"`
|
MessageID string `json:"messageId" csv:"messageId"`
|
||||||
PartsCount int `json:"partsCount"`
|
PartsCount int `json:"partsCount" csv:"partsCount"`
|
||||||
PhoneNumber string `json:"phoneNumber"`
|
Recipient string `json:"recipient" csv:"recipient"`
|
||||||
SentAt time.Time `json:"sentAt"`
|
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.
|
// WebhookDelivered represents a webhook notification for an SMS delivered event.
|
||||||
type WebhookDelivered struct {
|
type WebhookDelivered struct {
|
||||||
DeviceID string `json:"deviceId"`
|
DeviceID string `json:"deviceId" csv:"deviceId"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event" csv:"event"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id" csv:"id"`
|
||||||
Payload WebhookDeliveredPayload `json:"payload"`
|
Payload WebhookDeliveredPayload `json:"payload" csv:",inline"`
|
||||||
WebhookID string `json:"webhookId"`
|
WebhookID string `json:"webhookId" csv:"webhookId"`
|
||||||
XSignature string `json:"x-signature"`
|
XSignature string `json:"x-signature" csv:"-"`
|
||||||
XTimestamp int64 `json:"x-timestamp"`
|
XTimestamp int64 `json:"x-timestamp" csv:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEvent marks WebhookDelivered as part of the WebhookEvent interface.
|
// 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.
|
// WebhookDeliveredPayload contains details about the delivered SMS.
|
||||||
type WebhookDeliveredPayload struct {
|
type WebhookDeliveredPayload struct {
|
||||||
DeliveredAt time.Time `json:"deliveredAt"`
|
DeliveredAt time.Time `json:"deliveredAt" csv:"deliveredAt"`
|
||||||
MessageID string `json:"messageId"`
|
MessageID string `json:"messageId" csv:"messageId"`
|
||||||
PhoneNumber string `json:"phoneNumber"`
|
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.
|
// WebhookReceived represents a webhook notification for an SMS received event.
|
||||||
type WebhookReceived struct {
|
type WebhookReceived struct {
|
||||||
DeviceID string `json:"deviceId"`
|
DeviceID string `json:"deviceId" csv:"deviceId"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event" csv:"event"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id" csv:"id"`
|
||||||
Payload WebhookReceivedPayload `json:"payload"`
|
Payload WebhookReceivedPayload `json:"payload" csv:",inline"`
|
||||||
WebhookID string `json:"webhookId"`
|
WebhookID string `json:"webhookId" csv:"webhookId"`
|
||||||
XSignature string `json:"x-signature"`
|
XSignature string `json:"x-signature" csv:"-"`
|
||||||
XTimestamp int64 `json:"x-timestamp"`
|
XTimestamp int64 `json:"x-timestamp" csv:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEvent marks WebhookDelivered as part of the WebhookEvent interface.
|
// 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.
|
// WebhookReceivedPayload contains details about the received SMS.
|
||||||
type WebhookReceivedPayload struct {
|
type WebhookReceivedPayload struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message" csv:"message"`
|
||||||
MessageID string `json:"messageId"`
|
MessageID string `json:"messageId" csv:"messageId"`
|
||||||
PhoneNumber string `json:"phoneNumber"`
|
PhoneNumber string `json:"phoneNumber" csv:"phoneNumber"`
|
||||||
ReceivedAt time.Time `json:"receivedAt"`
|
ReceivedAt time.Time `json:"receivedAt" csv:"receivedAt"`
|
||||||
SimNumber int `json:"simNumber"`
|
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.
|
// WebhookPing represents a system:ping webhook event.
|
||||||
type WebhookPing struct {
|
type WebhookPing struct {
|
||||||
DeviceID string `json:"deviceId"`
|
DeviceID string `json:"deviceId" csv:"deviceId"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event" csv:"event"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id" csv:"id"`
|
||||||
Payload WebhookPingPayload `json:"payload"`
|
Payload WebhookPingPayload `json:"payload" csv:",inline"`
|
||||||
WebhookID string `json:"webhookId"`
|
WebhookID string `json:"webhookId" csv:"webhookId"`
|
||||||
XSignature string `json:"x-signature"`
|
PingedAt time.Time `json:"pingedAt,omitempty" csv:"pingedAt"`
|
||||||
XTimestamp int64 `json:"x-timestamp"`
|
XSignature string `json:"x-signature" csv:"-"`
|
||||||
|
XTimestamp int64 `json:"x-timestamp" csv:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEvent marks WebhookPing as part of the WebhookEvent interface.
|
// 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.
|
// WebhookPingPayload contains the health data reported by a system:ping event.
|
||||||
type WebhookPingPayload struct {
|
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.
|
// 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 {
|
type DeviceHealth struct {
|
||||||
Checks map[string]HealthCheck `json:"checks"`
|
Checks DeviceChecks `json:"checks" csv:",inline"`
|
||||||
ReleaseID int `json:"releaseId"`
|
ReleaseID int `json:"releaseId" csv:"releaseId"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status" csv:"status"`
|
||||||
Version string `json:"version"`
|
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.
|
// 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 {
|
type HealthCheck struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
ObservedUnit string `json:"observedUnit"`
|
ObservedUnit string `json:"observedUnit"`
|
||||||
@ -131,9 +233,106 @@ type HealthCheck struct {
|
|||||||
Status string `json:"status"`
|
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.
|
// Decode decodes the raw Payload based on the Event field and returns the appropriate WebhookEvent.
|
||||||
func Decode(webhook *Webhook) (WebhookEvent, error) {
|
func Decode(webhook *Webhook) (WebhookEvent, error) {
|
||||||
switch webhook.Event {
|
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":
|
case "sms:sent":
|
||||||
var sent WebhookSent
|
var sent WebhookSent
|
||||||
sent.DeviceID = webhook.DeviceID
|
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 nil, errors.New("failed to decode sms:delivered payload: " + err.Error())
|
||||||
}
|
}
|
||||||
return &delivered, nil
|
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":
|
case "sms:received":
|
||||||
var received WebhookReceived
|
var received WebhookReceived
|
||||||
received.DeviceID = webhook.DeviceID
|
received.DeviceID = webhook.DeviceID
|
||||||
|
|||||||
444
http/androidsmsgateway/webhooks_test.go
Normal file
444
http/androidsmsgateway/webhooks_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user