AtlatestRepositorysigil-ses
1
;;; Tests for sigil-ses3
(import (sigil core)4
(sigil dict)5
(sigil string)6
(sigil struct)7
(sigil json)8
(sigil test)9
(sigil crypto)10
(amazon ses)11
(amazon ses auth)12
(amazon ses notify))14
;; ---------------------------------------------------------------15
;; SigV4 signing tests16
;; ---------------------------------------------------------------18
;; AWS test vector values19
;; Reference: https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html20
(define test-access-key "AKIDEXAMPLE")21
(define test-secret-key "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY")22
(define test-region "us-east-1")23
(define test-service "iam")24
;; 2015-08-30T12:36:00Z as a time list: (sec min hour day month year weekday yearday dst)25
(define test-time-list '(0 36 12 30 8 2015 0 0 0))27
(test-group "date formatting"29
(test "format-amz-date"30
(assert-equal "20150830T123600Z"31
(format-amz-date test-time-list)))33
(test "format-date-stamp"34
(assert-equal "20150830"35
(format-date-stamp test-time-list))))37
(test-group "hex conversion"39
(test "hex->bytevector basic"40
(let ((bv (hex->bytevector "deadbeef")))41
(assert-equal 4 (bytevector-length bv))42
(assert-equal #xde (bytevector-u8-ref bv 0))43
(assert-equal #xad (bytevector-u8-ref bv 1))44
(assert-equal #xbe (bytevector-u8-ref bv 2))45
(assert-equal #xef (bytevector-u8-ref bv 3))))47
(test "hex->bytevector empty"48
(let ((bv (hex->bytevector "")))49
(assert-equal 0 (bytevector-length bv)))))51
(test-group "signing key derivation"53
(test "derive-signing-key matches AWS test vector"54
;; Expected signing key for the AWS test vector:55
;; Secret: wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY56
;; Date: 20150830, Region: us-east-1, Service: iam57
(let* ((key (derive-signing-key test-secret-key "20150830"58
test-region test-service))59
;; Convert to hex for comparison60
(key-hex (apply string-append61
(map (lambda (i)62
(let ((b (bytevector-u8-ref key i)))63
(string-append64
(if (< b 16) "0" "")65
(number->string b 16))))66
(iota (bytevector-length key))))))67
(assert-equal "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9"68
key-hex))))70
(test-group "canonical request"72
(test "canonical request with headers"73
(let* ((headers #{ host: "iam.amazonaws.com"74
content-type: "application/x-www-form-urlencoded; charset=utf-8"75
x-amz-date: "20150830T123600Z" })76
(hp (canonical-headers-pair headers))77
(creq (canonical-request "GET" "/" "" hp (sha256 ""))))78
;; Verify it starts with GET and contains sorted headers79
(assert-true (string-contains? creq "GET"))80
(assert-true (string-contains? creq "content-type:application/x-www-form-urlencoded; charset=utf-8"))81
(assert-true (string-contains? creq "host:iam.amazonaws.com"))82
(assert-true (string-contains? creq "x-amz-date:20150830T123600Z")))))84
(test-group "full sigv4 signing"86
(test "sigv4-sign-request produces authorization header"87
(let ((headers (sigv4-sign-request88
test-access-key test-secret-key89
test-region test-service90
"GET" "iam.amazonaws.com" "/" ""91
#{ content-type: "application/x-www-form-urlencoded; charset=utf-8" }92
""93
test-time-list)))94
;; Check authorization header exists and has correct format95
(let ((auth (dict-ref headers authorization:)))96
(assert-true (string-starts-with? auth "AWS4-HMAC-SHA256 "))97
(assert-true (string-contains? auth "Credential=AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request"))98
(assert-true (string-contains? auth "SignedHeaders="))99
(assert-true (string-contains? auth "Signature=")))))101
(test "sigv4-sign-request includes x-amz-date"102
(let ((headers (sigv4-sign-request103
test-access-key test-secret-key104
test-region "ses"105
"POST" "email.us-east-2.amazonaws.com"106
"/v2/email/outbound-emails" ""107
#{ content-type: "application/json" }108
"{\"test\": true}"109
test-time-list)))110
(assert-equal "20150830T123600Z"111
(dict-ref headers x-amz-date:)))))113
;; ---------------------------------------------------------------114
;; Client construction tests115
;; ---------------------------------------------------------------117
(test-group "client construction"119
(test "default region"120
(let ((c (ses-client access-key-id: "AK"121
secret-access-key: "SK")))122
(assert-true (ses-client? c))123
(assert-equal "AK" (ses-client-access-key-id c))124
(assert-equal "SK" (ses-client-secret-access-key c))125
(assert-equal "us-east-2" (ses-client-region c))))127
(test "custom region"128
(let ((c (ses-client access-key-id: "AK"129
secret-access-key: "SK"130
region: "eu-west-1")))131
(assert-equal "eu-west-1" (ses-client-region c)))))133
;; ---------------------------------------------------------------134
;; Response parsing tests135
;; ---------------------------------------------------------------137
(test-group "account parsing"139
(test "parse account info"140
(let ((data #{ SendQuota: #{ Max24HourSend: 50000141
MaxSendRate: 14142
SentLast24Hours: 127 }143
EnforcementStatus: "HEALTHY" }))144
(let ((acct (parse-account data)))145
(assert-true (ses-account? acct))146
(assert-equal 50000 (ses-account-send-quota acct))147
(assert-equal 14 (ses-account-send-rate acct))148
(assert-equal 127 (ses-account-sent-last-24h acct))149
(assert-equal "HEALTHY" (ses-account-enforcement-status acct))))))151
(test-group "suppressed address parsing"153
(test "parse suppressed address"154
(let ((data #{ EmailAddress: "[email protected]"155
Reason: "BOUNCE"156
LastUpdateTime: "2026-03-15T10:00:00Z" }))157
(let ((addr (parse-suppressed-address data)))158
(assert-true (ses-suppressed-address? addr))159
(assert-equal "[email protected]" (ses-suppressed-address-email addr))160
(assert-equal "BOUNCE" (ses-suppressed-address-reason addr))161
(assert-equal "2026-03-15T10:00:00Z"162
(ses-suppressed-address-last-update addr))))))164
(test-group "identity parsing"166
(test "parse verified identity"167
(let ((data #{ VerifiedForSendingStatus: #t168
DkimAttributes: #{ Status: "SUCCESS" }169
MailFromAttributes: #{ MailFromDomainStatus: "SUCCESS" } }))170
(let ((id (parse-identity "systemcrafters.net" data)))171
(assert-true (ses-identity? id))172
(assert-equal "systemcrafters.net" (ses-identity-name id))173
(assert-true (ses-identity-verified? id))174
(assert-equal "SUCCESS" (ses-identity-dkim-status id))175
(assert-equal "SUCCESS" (ses-identity-mail-from-status id))))))177
;; ---------------------------------------------------------------178
;; SNS notification parsing tests179
;; ---------------------------------------------------------------181
(define bounce-json182
(json-decode183
(string-append184
"{\"notificationType\": \"Bounce\","185
" \"bounce\": {"186
" \"bounceType\": \"Permanent\","187
" \"bounceSubType\": \"General\","188
" \"bouncedRecipients\": ["189
" {\"emailAddress\": \"[email protected]\"},"190
" {\"emailAddress\": \"[email protected]\"}"191
" ],"192
" \"timestamp\": \"2026-03-15T10:30:00Z\","193
" \"feedbackId\": \"feedback-123\""194
" }}")))196
(define complaint-json197
(json-decode198
(string-append199
"{\"notificationType\": \"Complaint\","200
" \"complaint\": {"201
" \"complainedRecipients\": ["202
" {\"emailAddress\": \"[email protected]\"}"203
" ],"204
" \"complaintFeedbackType\": \"abuse\","205
" \"timestamp\": \"2026-03-16T14:00:00Z\","206
" \"feedbackId\": \"feedback-456\""207
" }}")))209
(define delivery-json210
(json-decode "{\"notificationType\": \"Delivery\"}"))212
(test-group "bounce notification parsing"214
(test "parse bounce notification"215
(let ((n (parse-ses-notification bounce-json)))216
(assert-true (ses-notification? n))217
(assert-equal "Bounce" (ses-notification-type n))218
(assert-equal "Permanent" (ses-notification-bounce-type n))219
(assert-equal "General" (ses-notification-bounce-sub-type n))220
(assert-equal #f (ses-notification-complaint-type n))221
(assert-equal "2026-03-15T10:30:00Z" (ses-notification-timestamp n))222
(assert-equal "feedback-123" (ses-notification-feedback-id n))))224
(test "bounce addresses extracted"225
(let ((n (parse-ses-notification bounce-json)))226
(let ((addrs (ses-notification-addresses n)))227
(assert-equal 2 (length addrs))228
(assert-equal "[email protected]" (car addrs))229
(assert-equal "[email protected]" (cadr addrs)))))231
(test "ses-bounce? predicate"232
(let ((n (parse-ses-notification bounce-json)))233
(assert-true (ses-bounce? n))234
(assert-false (ses-complaint? n))))236
(test "ses-hard-bounce? predicate"237
(let ((n (parse-ses-notification bounce-json)))238
(assert-true (ses-hard-bounce? n)))))240
(test-group "complaint notification parsing"242
(test "parse complaint notification"243
(let ((n (parse-ses-notification complaint-json)))244
(assert-true (ses-notification? n))245
(assert-equal "Complaint" (ses-notification-type n))246
(assert-equal "abuse" (ses-notification-complaint-type n))247
(assert-equal #f (ses-notification-bounce-type n))248
(assert-equal "2026-03-16T14:00:00Z" (ses-notification-timestamp n))249
(assert-equal "feedback-456" (ses-notification-feedback-id n))))251
(test "complaint addresses extracted"252
(let ((n (parse-ses-notification complaint-json)))253
(let ((addrs (ses-notification-addresses n)))254
(assert-equal 1 (length addrs))255
(assert-equal "[email protected]" (car addrs)))))257
(test "ses-complaint? predicate"258
(let ((n (parse-ses-notification complaint-json)))259
(assert-true (ses-complaint? n))260
(assert-false (ses-bounce? n)))))262
(test-group "other notification types"264
(test "delivery notification returns #f"265
(let ((n (parse-ses-notification delivery-json)))266
(assert-false n))))268
;; ---------------------------------------------------------------269
;; Record construction tests270
;; ---------------------------------------------------------------272
(test-group "record construction"274
(test "ses-email-result fields"275
(let ((r (ses-email-result message-id: "msg-123")))276
(assert-true (ses-email-result? r))277
(assert-equal "msg-123" (ses-email-result-message-id r))))279
(test "ses-account fields"280
(let ((a (ses-account send-quota: 50000 send-rate: 14281
sent-last-24h: 0 enforcement-status: "HEALTHY")))282
(assert-equal 50000 (ses-account-send-quota a))283
(assert-equal "HEALTHY" (ses-account-enforcement-status a))))285
(test "ses-notification fields"286
(let ((n (ses-notification type: "Bounce"287
addresses: '("[email protected]")288
bounce-type: "Permanent"289
bounce-sub-type: "General"290
complaint-type: #f291
timestamp: "2026-01-01T00:00:00Z"292
feedback-id: "fb-1"293
raw: #{})))294
(assert-true (ses-notification? n))295
(assert-equal "Bounce" (ses-notification-type n))296
(assert-equal '("[email protected]") (ses-notification-addresses n)))))298
(run-tests)