mppx 0.6.20 → 0.6.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/client/Mppx.d.ts +12 -1
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +127 -10
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +69 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +250 -20
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts +1 -1
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +2 -1
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/server/Mppx.d.ts +82 -3
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +557 -83
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/Subscription.d.ts.map +1 -1
- package/dist/tempo/client/Subscription.js +4 -3
- package/dist/tempo/client/Subscription.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +1 -1
- package/src/client/Mppx.test-d.ts +55 -0
- package/src/client/Mppx.test.ts +181 -0
- package/src/client/Mppx.ts +248 -16
- package/src/client/internal/Fetch.test-d.ts +31 -0
- package/src/client/internal/Fetch.test.ts +261 -0
- package/src/client/internal/Fetch.ts +467 -24
- package/src/middlewares/internal/mppx.ts +5 -6
- package/src/proxy/Proxy.test.ts +69 -0
- package/src/server/Mppx.test-d.ts +50 -0
- package/src/server/Mppx.test.ts +893 -1
- package/src/server/Mppx.ts +862 -97
- package/src/tempo/client/Subscription.test.ts +51 -0
- package/src/tempo/client/Subscription.ts +4 -3
- package/src/tempo/server/internal/html.gen.ts +1 -1
package/src/server/Mppx.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as http from 'node:http'
|
|
2
2
|
|
|
3
|
-
import { Challenge, Credential, Errors, Method, z } from 'mppx'
|
|
3
|
+
import { Challenge, Credential, Errors, Method, Receipt, z } from 'mppx'
|
|
4
4
|
import {
|
|
5
5
|
Mppx as Mppx_client,
|
|
6
6
|
session as tempo_session_client,
|
|
@@ -914,6 +914,893 @@ describe('receipt handling', () => {
|
|
|
914
914
|
})
|
|
915
915
|
})
|
|
916
916
|
|
|
917
|
+
describe('server events', () => {
|
|
918
|
+
const eventCharge = Method.from({
|
|
919
|
+
name: 'mock',
|
|
920
|
+
intent: 'charge',
|
|
921
|
+
schema: {
|
|
922
|
+
credential: {
|
|
923
|
+
payload: z.object({ token: z.string() }),
|
|
924
|
+
},
|
|
925
|
+
request: z.object({
|
|
926
|
+
amount: z.string(),
|
|
927
|
+
currency: z.string(),
|
|
928
|
+
decimals: z.number(),
|
|
929
|
+
recipient: z.string(),
|
|
930
|
+
}),
|
|
931
|
+
},
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
function options() {
|
|
935
|
+
return {
|
|
936
|
+
amount: '1000',
|
|
937
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
938
|
+
decimals: 6,
|
|
939
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
940
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function receipt(reference = 'tx-events') {
|
|
945
|
+
return {
|
|
946
|
+
method: 'mock',
|
|
947
|
+
reference,
|
|
948
|
+
status: 'success' as const,
|
|
949
|
+
timestamp: new Date().toISOString(),
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
test('emits challenge then success events for a successful request', async () => {
|
|
954
|
+
const events: string[] = []
|
|
955
|
+
const seen: Record<string, unknown> = {}
|
|
956
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
957
|
+
async verify() {
|
|
958
|
+
return receipt()
|
|
959
|
+
},
|
|
960
|
+
})
|
|
961
|
+
const handler = Mppx.create({
|
|
962
|
+
methods: [serverMethod],
|
|
963
|
+
realm,
|
|
964
|
+
secretKey,
|
|
965
|
+
})
|
|
966
|
+
handler.onChallengeCreated((context) => {
|
|
967
|
+
events.push(`challenge:${context.error?.name}`)
|
|
968
|
+
seen.challengeMethod = context.method.name
|
|
969
|
+
seen.challengeMethodKeys = Object.keys(context.method)
|
|
970
|
+
seen.challengePath = context.capturedRequest?.url.pathname
|
|
971
|
+
seen.challengeAmount = context.request.amount
|
|
972
|
+
seen.challengeCredential = context.credential
|
|
973
|
+
})
|
|
974
|
+
handler.onPaymentSuccess((context) => {
|
|
975
|
+
events.push(`payment:${context.receipt.reference}`)
|
|
976
|
+
seen.paymentMethod = context.method.name
|
|
977
|
+
seen.paymentChallenge = context.challenge.id
|
|
978
|
+
seen.paymentEnvelope = context.envelope?.challenge.id
|
|
979
|
+
seen.paymentToken = context.credential?.payload.token
|
|
980
|
+
seen.paymentAmount = context.request.amount
|
|
981
|
+
})
|
|
982
|
+
handler.onPaymentFailed(() => {
|
|
983
|
+
events.push('failed')
|
|
984
|
+
})
|
|
985
|
+
const handle = handler.charge(options())
|
|
986
|
+
|
|
987
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
988
|
+
expect(first.status).toBe(402)
|
|
989
|
+
if (first.status !== 402) throw new Error()
|
|
990
|
+
|
|
991
|
+
const challenge = Challenge.fromResponse(first.challenge)
|
|
992
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
993
|
+
const paid = await handle(
|
|
994
|
+
new Request('https://example.com/resource', {
|
|
995
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
996
|
+
}),
|
|
997
|
+
)
|
|
998
|
+
expect(paid.status).toBe(200)
|
|
999
|
+
if (paid.status !== 200) throw new Error()
|
|
1000
|
+
|
|
1001
|
+
const response = paid.withReceipt(Response.json({ ok: true }))
|
|
1002
|
+
|
|
1003
|
+
expect(response.headers.get('Payment-Receipt')).toBeTruthy()
|
|
1004
|
+
expect(events).toEqual(['challenge:PaymentRequiredError', 'payment:tx-events'])
|
|
1005
|
+
expect(seen).toMatchObject({
|
|
1006
|
+
challengeAmount: '1000',
|
|
1007
|
+
challengeCredential: null,
|
|
1008
|
+
challengeMethod: 'mock',
|
|
1009
|
+
challengeMethodKeys: ['intent', 'name'],
|
|
1010
|
+
challengePath: '/resource',
|
|
1011
|
+
paymentAmount: '1000',
|
|
1012
|
+
paymentChallenge: challenge.id,
|
|
1013
|
+
paymentEnvelope: challenge.id,
|
|
1014
|
+
paymentMethod: 'mock',
|
|
1015
|
+
paymentToken: 'valid',
|
|
1016
|
+
})
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
test('does not let server event errors alter payment control flow', async () => {
|
|
1020
|
+
const events: string[] = []
|
|
1021
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1022
|
+
async verify() {
|
|
1023
|
+
return receipt('tx-event-error')
|
|
1024
|
+
},
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
const handler = Mppx.create({
|
|
1028
|
+
methods: [serverMethod],
|
|
1029
|
+
realm,
|
|
1030
|
+
secretKey,
|
|
1031
|
+
})
|
|
1032
|
+
handler.on('*', async () => {
|
|
1033
|
+
events.push('*')
|
|
1034
|
+
throw new Error('catchall event failed')
|
|
1035
|
+
})
|
|
1036
|
+
handler.onChallengeCreated(() => {
|
|
1037
|
+
events.push('challenge.created')
|
|
1038
|
+
throw new Error('challenge event failed')
|
|
1039
|
+
})
|
|
1040
|
+
handler.onPaymentFailed(async () => {
|
|
1041
|
+
events.push('failed')
|
|
1042
|
+
throw new Error('failed event failed')
|
|
1043
|
+
})
|
|
1044
|
+
handler.onPaymentSuccess(async () => {
|
|
1045
|
+
events.push('success')
|
|
1046
|
+
throw new Error('success event failed')
|
|
1047
|
+
})
|
|
1048
|
+
const handle = handler.charge(options())
|
|
1049
|
+
|
|
1050
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1051
|
+
expect(first.status).toBe(402)
|
|
1052
|
+
if (first.status !== 402) throw new Error()
|
|
1053
|
+
|
|
1054
|
+
const invalid = await handle(
|
|
1055
|
+
new Request('https://example.com/resource', {
|
|
1056
|
+
headers: {
|
|
1057
|
+
Authorization: Credential.serialize(
|
|
1058
|
+
Credential.from({
|
|
1059
|
+
challenge: Challenge.from({
|
|
1060
|
+
id: 'wrong-id',
|
|
1061
|
+
intent: 'charge',
|
|
1062
|
+
method: 'mock',
|
|
1063
|
+
realm,
|
|
1064
|
+
request: {
|
|
1065
|
+
amount: '1000',
|
|
1066
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1067
|
+
decimals: 6,
|
|
1068
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1069
|
+
},
|
|
1070
|
+
}),
|
|
1071
|
+
payload: { token: 'valid' },
|
|
1072
|
+
}),
|
|
1073
|
+
),
|
|
1074
|
+
},
|
|
1075
|
+
}),
|
|
1076
|
+
)
|
|
1077
|
+
expect(invalid.status).toBe(402)
|
|
1078
|
+
|
|
1079
|
+
const paid = await handle(
|
|
1080
|
+
new Request('https://example.com/resource', {
|
|
1081
|
+
headers: {
|
|
1082
|
+
Authorization: Credential.serialize(
|
|
1083
|
+
Credential.from({
|
|
1084
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1085
|
+
payload: { token: 'valid' },
|
|
1086
|
+
}),
|
|
1087
|
+
),
|
|
1088
|
+
},
|
|
1089
|
+
}),
|
|
1090
|
+
)
|
|
1091
|
+
expect(paid.status).toBe(200)
|
|
1092
|
+
if (paid.status !== 200) throw new Error()
|
|
1093
|
+
expect(paid.withReceipt(Response.json({ ok: true })).status).toBe(200)
|
|
1094
|
+
|
|
1095
|
+
expect(events).toEqual([
|
|
1096
|
+
'challenge.created',
|
|
1097
|
+
'*',
|
|
1098
|
+
'failed',
|
|
1099
|
+
'*',
|
|
1100
|
+
'challenge.created',
|
|
1101
|
+
'*',
|
|
1102
|
+
'success',
|
|
1103
|
+
'*',
|
|
1104
|
+
])
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
test('continues dispatching later listeners after an earlier listener throws', async () => {
|
|
1108
|
+
const events: string[] = []
|
|
1109
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1110
|
+
async verify() {
|
|
1111
|
+
return receipt('tx-listener-isolation')
|
|
1112
|
+
},
|
|
1113
|
+
})
|
|
1114
|
+
const handler = Mppx.create({
|
|
1115
|
+
methods: [serverMethod],
|
|
1116
|
+
realm,
|
|
1117
|
+
secretKey,
|
|
1118
|
+
})
|
|
1119
|
+
handler.onPaymentSuccess(() => {
|
|
1120
|
+
events.push('first')
|
|
1121
|
+
throw new Error('listener failed')
|
|
1122
|
+
})
|
|
1123
|
+
handler.onPaymentSuccess(() => {
|
|
1124
|
+
events.push('second')
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
const handle = handler.charge(options())
|
|
1128
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1129
|
+
expect(first.status).toBe(402)
|
|
1130
|
+
if (first.status !== 402) throw new Error()
|
|
1131
|
+
|
|
1132
|
+
const paid = await handle(
|
|
1133
|
+
new Request('https://example.com/resource', {
|
|
1134
|
+
headers: {
|
|
1135
|
+
Authorization: Credential.serialize(
|
|
1136
|
+
Credential.from({
|
|
1137
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1138
|
+
payload: { token: 'valid' },
|
|
1139
|
+
}),
|
|
1140
|
+
),
|
|
1141
|
+
},
|
|
1142
|
+
}),
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
expect(paid.status).toBe(200)
|
|
1146
|
+
expect(events).toEqual(['first', 'second'])
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
test('supports canonical event names and generated registration methods', async () => {
|
|
1150
|
+
const events: string[] = []
|
|
1151
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1152
|
+
async verify() {
|
|
1153
|
+
return receipt('tx-registered-event')
|
|
1154
|
+
},
|
|
1155
|
+
})
|
|
1156
|
+
const handler = Mppx.create({
|
|
1157
|
+
methods: [serverMethod],
|
|
1158
|
+
realm,
|
|
1159
|
+
secretKey,
|
|
1160
|
+
})
|
|
1161
|
+
const offFailed = handler.on('payment.failed', (context) => {
|
|
1162
|
+
events.push(`failed:${context.error.name}`)
|
|
1163
|
+
})
|
|
1164
|
+
const offChallengeCreated = handler.on('challenge.created', (context) => {
|
|
1165
|
+
events.push(`runtime:${context.error?.name}`)
|
|
1166
|
+
})
|
|
1167
|
+
const offAll = handler.on('*', (event) => {
|
|
1168
|
+
events.push(`runtime:*:${event.name}`)
|
|
1169
|
+
})
|
|
1170
|
+
const offSuccess = handler.onPaymentSuccess((context) => {
|
|
1171
|
+
events.push(`success:${context.receipt.reference}`)
|
|
1172
|
+
})
|
|
1173
|
+
offFailed()
|
|
1174
|
+
|
|
1175
|
+
const handle = handler.charge(options())
|
|
1176
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1177
|
+
expect(first.status).toBe(402)
|
|
1178
|
+
if (first.status !== 402) throw new Error()
|
|
1179
|
+
offChallengeCreated()
|
|
1180
|
+
offAll()
|
|
1181
|
+
|
|
1182
|
+
const badCredential = Credential.from({
|
|
1183
|
+
challenge: Challenge.from({
|
|
1184
|
+
id: 'wrong-id',
|
|
1185
|
+
intent: 'charge',
|
|
1186
|
+
method: 'mock',
|
|
1187
|
+
realm,
|
|
1188
|
+
request: {
|
|
1189
|
+
amount: '1000',
|
|
1190
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1191
|
+
decimals: 6,
|
|
1192
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1193
|
+
},
|
|
1194
|
+
}),
|
|
1195
|
+
payload: { token: 'valid' },
|
|
1196
|
+
})
|
|
1197
|
+
const invalid = await handle(
|
|
1198
|
+
new Request('https://example.com/resource', {
|
|
1199
|
+
headers: { Authorization: Credential.serialize(badCredential) },
|
|
1200
|
+
}),
|
|
1201
|
+
)
|
|
1202
|
+
expect(invalid.status).toBe(402)
|
|
1203
|
+
|
|
1204
|
+
const paid = await handle(
|
|
1205
|
+
new Request('https://example.com/resource', {
|
|
1206
|
+
headers: {
|
|
1207
|
+
Authorization: Credential.serialize(
|
|
1208
|
+
Credential.from({
|
|
1209
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1210
|
+
payload: { token: 'valid' },
|
|
1211
|
+
}),
|
|
1212
|
+
),
|
|
1213
|
+
},
|
|
1214
|
+
}),
|
|
1215
|
+
)
|
|
1216
|
+
expect(paid.status).toBe(200)
|
|
1217
|
+
offSuccess()
|
|
1218
|
+
|
|
1219
|
+
expect(events).toEqual([
|
|
1220
|
+
'runtime:PaymentRequiredError',
|
|
1221
|
+
'runtime:*:challenge.created',
|
|
1222
|
+
'success:tx-registered-event',
|
|
1223
|
+
])
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
test('emits payment failure before reissuing challenge for invalid credentials', async () => {
|
|
1227
|
+
const events: string[] = []
|
|
1228
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1229
|
+
async verify() {
|
|
1230
|
+
return receipt()
|
|
1231
|
+
},
|
|
1232
|
+
})
|
|
1233
|
+
const handler = Mppx.create({
|
|
1234
|
+
methods: [serverMethod],
|
|
1235
|
+
realm,
|
|
1236
|
+
secretKey,
|
|
1237
|
+
})
|
|
1238
|
+
handler.onChallengeCreated((context) => {
|
|
1239
|
+
events.push(`challenge:${context.error?.name}`)
|
|
1240
|
+
})
|
|
1241
|
+
handler.onPaymentSuccess((context) => {
|
|
1242
|
+
events.push(`payment:${context.receipt.reference}`)
|
|
1243
|
+
})
|
|
1244
|
+
handler.onPaymentFailed((context) => {
|
|
1245
|
+
events.push(`failed:${context.error.name}:${context.credential?.challenge.id}`)
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
const badChallenge = Challenge.from({
|
|
1249
|
+
id: 'wrong-id',
|
|
1250
|
+
intent: 'charge',
|
|
1251
|
+
method: 'mock',
|
|
1252
|
+
realm,
|
|
1253
|
+
request: {
|
|
1254
|
+
amount: '1000',
|
|
1255
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1256
|
+
decimals: 6,
|
|
1257
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1258
|
+
},
|
|
1259
|
+
})
|
|
1260
|
+
const credential = Credential.from({
|
|
1261
|
+
challenge: badChallenge,
|
|
1262
|
+
payload: { token: 'valid' },
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
const result = await handler.charge(options())(
|
|
1266
|
+
new Request('https://example.com/resource', {
|
|
1267
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1268
|
+
}),
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
expect(result.status).toBe(402)
|
|
1272
|
+
expect(events).toEqual([
|
|
1273
|
+
'failed:InvalidChallengeError:wrong-id',
|
|
1274
|
+
'challenge:InvalidChallengeError',
|
|
1275
|
+
])
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
test('emits payment failure for malformed credentials with no parsed credential', async () => {
|
|
1279
|
+
const events: string[] = []
|
|
1280
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1281
|
+
async verify() {
|
|
1282
|
+
return receipt()
|
|
1283
|
+
},
|
|
1284
|
+
})
|
|
1285
|
+
const handler = Mppx.create({
|
|
1286
|
+
methods: [serverMethod],
|
|
1287
|
+
realm,
|
|
1288
|
+
secretKey,
|
|
1289
|
+
})
|
|
1290
|
+
handler.onChallengeCreated((context) => {
|
|
1291
|
+
events.push(`challenge:${context.error?.name}:${context.credential}`)
|
|
1292
|
+
})
|
|
1293
|
+
handler.onPaymentFailed((context) => {
|
|
1294
|
+
events.push(`failed:${context.error.name}:${context.credential}`)
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
const result = await handler.charge(options())(
|
|
1298
|
+
new Request('https://example.com/resource', {
|
|
1299
|
+
headers: { Authorization: 'Payment invalid' },
|
|
1300
|
+
}),
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
expect(result.status).toBe(402)
|
|
1304
|
+
expect(events).toEqual([
|
|
1305
|
+
'failed:MalformedCredentialError:null',
|
|
1306
|
+
'challenge:MalformedCredentialError:null',
|
|
1307
|
+
])
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
test('emits payment failure when method verification rejects', async () => {
|
|
1311
|
+
const events: string[] = []
|
|
1312
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1313
|
+
async verify() {
|
|
1314
|
+
throw new Errors.VerificationFailedError({ reason: 'declined' })
|
|
1315
|
+
},
|
|
1316
|
+
})
|
|
1317
|
+
const handler = Mppx.create({
|
|
1318
|
+
methods: [serverMethod],
|
|
1319
|
+
realm,
|
|
1320
|
+
secretKey,
|
|
1321
|
+
})
|
|
1322
|
+
handler.onChallengeCreated((context) => {
|
|
1323
|
+
events.push(`challenge:${context.error?.name}`)
|
|
1324
|
+
})
|
|
1325
|
+
handler.onPaymentSuccess(() => {
|
|
1326
|
+
events.push('payment')
|
|
1327
|
+
})
|
|
1328
|
+
handler.onPaymentFailed((context) => {
|
|
1329
|
+
events.push(`failed:${context.error.name}`)
|
|
1330
|
+
})
|
|
1331
|
+
const handle = handler.charge(options())
|
|
1332
|
+
|
|
1333
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1334
|
+
expect(first.status).toBe(402)
|
|
1335
|
+
if (first.status !== 402) throw new Error()
|
|
1336
|
+
|
|
1337
|
+
const credential = Credential.from({
|
|
1338
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1339
|
+
payload: { token: 'valid' },
|
|
1340
|
+
})
|
|
1341
|
+
const result = await handle(
|
|
1342
|
+
new Request('https://example.com/resource', {
|
|
1343
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1344
|
+
}),
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
expect(result.status).toBe(402)
|
|
1348
|
+
expect(events).toEqual([
|
|
1349
|
+
'challenge:PaymentRequiredError',
|
|
1350
|
+
'failed:VerificationFailedError',
|
|
1351
|
+
'challenge:VerificationFailedError',
|
|
1352
|
+
])
|
|
1353
|
+
})
|
|
1354
|
+
|
|
1355
|
+
test('emits payment success when authorize grants access without a credential', async () => {
|
|
1356
|
+
const events: string[] = []
|
|
1357
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1358
|
+
async authorize() {
|
|
1359
|
+
return { receipt: receipt('tx-authorized') }
|
|
1360
|
+
},
|
|
1361
|
+
async verify() {
|
|
1362
|
+
return receipt()
|
|
1363
|
+
},
|
|
1364
|
+
})
|
|
1365
|
+
const handler = Mppx.create({
|
|
1366
|
+
methods: [serverMethod],
|
|
1367
|
+
realm,
|
|
1368
|
+
secretKey,
|
|
1369
|
+
})
|
|
1370
|
+
handler.onPaymentSuccess((context) => {
|
|
1371
|
+
events.push(`success:${context.receipt.reference}:${context.credential}:${context.envelope}`)
|
|
1372
|
+
})
|
|
1373
|
+
|
|
1374
|
+
const result = await handler.charge(options())(new Request('https://example.com/resource'))
|
|
1375
|
+
|
|
1376
|
+
expect(result.status).toBe(200)
|
|
1377
|
+
expect(events).toEqual(['success:tx-authorized:undefined:undefined'])
|
|
1378
|
+
})
|
|
1379
|
+
|
|
1380
|
+
test('emits payment failure when authorize rejects', async () => {
|
|
1381
|
+
const events: string[] = []
|
|
1382
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1383
|
+
async authorize() {
|
|
1384
|
+
throw new Errors.VerificationFailedError({ reason: 'not active' })
|
|
1385
|
+
},
|
|
1386
|
+
async verify() {
|
|
1387
|
+
return receipt()
|
|
1388
|
+
},
|
|
1389
|
+
})
|
|
1390
|
+
const handler = Mppx.create({
|
|
1391
|
+
methods: [serverMethod],
|
|
1392
|
+
realm,
|
|
1393
|
+
secretKey,
|
|
1394
|
+
})
|
|
1395
|
+
handler.onPaymentFailed((context) => {
|
|
1396
|
+
events.push(`failed:${context.error.name}:${context.credential}`)
|
|
1397
|
+
})
|
|
1398
|
+
|
|
1399
|
+
const result = await handler.charge(options())(new Request('https://example.com/resource'))
|
|
1400
|
+
|
|
1401
|
+
expect(result.status).toBe(402)
|
|
1402
|
+
expect(events).toEqual(['failed:VerificationFailedError:null'])
|
|
1403
|
+
})
|
|
1404
|
+
|
|
1405
|
+
test('emits events from standalone verifyCredential', async () => {
|
|
1406
|
+
const events: string[] = []
|
|
1407
|
+
const capturedRequest = {
|
|
1408
|
+
headers: new Headers({ 'x-route': 'standalone' }),
|
|
1409
|
+
hasBody: false,
|
|
1410
|
+
method: 'POST',
|
|
1411
|
+
url: new URL('https://example.com/standalone'),
|
|
1412
|
+
}
|
|
1413
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1414
|
+
async verify() {
|
|
1415
|
+
return receipt('tx-standalone')
|
|
1416
|
+
},
|
|
1417
|
+
})
|
|
1418
|
+
const handler = Mppx.create({
|
|
1419
|
+
methods: [serverMethod],
|
|
1420
|
+
realm,
|
|
1421
|
+
secretKey,
|
|
1422
|
+
})
|
|
1423
|
+
handler.onPaymentSuccess((context) => {
|
|
1424
|
+
events.push(
|
|
1425
|
+
`success:${context.receipt.reference}:${context.input}:${context.capturedRequest?.url.pathname}`,
|
|
1426
|
+
)
|
|
1427
|
+
})
|
|
1428
|
+
|
|
1429
|
+
const first = await handler.charge(options())(new Request('https://example.com/resource'))
|
|
1430
|
+
expect(first.status).toBe(402)
|
|
1431
|
+
if (first.status !== 402) throw new Error()
|
|
1432
|
+
|
|
1433
|
+
const result = await handler.verifyCredential(
|
|
1434
|
+
Credential.from({
|
|
1435
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1436
|
+
payload: { token: 'valid' },
|
|
1437
|
+
}),
|
|
1438
|
+
{ capturedRequest, request: options() },
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
expect(result.reference).toBe('tx-standalone')
|
|
1442
|
+
expect(events).toEqual(['success:tx-standalone:undefined:/standalone'])
|
|
1443
|
+
})
|
|
1444
|
+
|
|
1445
|
+
test('emits payment failure from standalone verifyCredential failures', async () => {
|
|
1446
|
+
const events: string[] = []
|
|
1447
|
+
const capturedRequest = {
|
|
1448
|
+
headers: new Headers({ 'x-route': 'standalone' }),
|
|
1449
|
+
hasBody: false,
|
|
1450
|
+
method: 'POST',
|
|
1451
|
+
url: new URL('https://example.com/standalone-failure'),
|
|
1452
|
+
}
|
|
1453
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1454
|
+
async verify() {
|
|
1455
|
+
throw new Errors.VerificationFailedError({ reason: 'declined' })
|
|
1456
|
+
},
|
|
1457
|
+
})
|
|
1458
|
+
const handler = Mppx.create({
|
|
1459
|
+
methods: [serverMethod],
|
|
1460
|
+
realm,
|
|
1461
|
+
secretKey,
|
|
1462
|
+
})
|
|
1463
|
+
handler.onPaymentFailed((context) => {
|
|
1464
|
+
events.push(
|
|
1465
|
+
`failed:${context.error.name}:${context.submittedChallenge?.id}:${context.capturedRequest?.url.pathname}`,
|
|
1466
|
+
)
|
|
1467
|
+
})
|
|
1468
|
+
|
|
1469
|
+
const first = await handler.charge(options())(new Request('https://example.com/resource'))
|
|
1470
|
+
expect(first.status).toBe(402)
|
|
1471
|
+
if (first.status !== 402) throw new Error()
|
|
1472
|
+
const challenge = Challenge.fromResponse(first.challenge)
|
|
1473
|
+
|
|
1474
|
+
await expect(
|
|
1475
|
+
handler.verifyCredential(
|
|
1476
|
+
Credential.from({
|
|
1477
|
+
challenge,
|
|
1478
|
+
payload: { token: 'valid' },
|
|
1479
|
+
}),
|
|
1480
|
+
{ capturedRequest, request: options() },
|
|
1481
|
+
),
|
|
1482
|
+
).rejects.toThrow(Errors.VerificationFailedError)
|
|
1483
|
+
expect(events).toEqual([`failed:VerificationFailedError:${challenge.id}:/standalone-failure`])
|
|
1484
|
+
})
|
|
1485
|
+
|
|
1486
|
+
test('emits standalone payment failure for forged challenge HMAC failures', async () => {
|
|
1487
|
+
const events: string[] = []
|
|
1488
|
+
let failedRequestAmount: unknown
|
|
1489
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1490
|
+
async verify() {
|
|
1491
|
+
return receipt()
|
|
1492
|
+
},
|
|
1493
|
+
})
|
|
1494
|
+
const handler = Mppx.create({
|
|
1495
|
+
methods: [serverMethod],
|
|
1496
|
+
realm,
|
|
1497
|
+
secretKey,
|
|
1498
|
+
})
|
|
1499
|
+
handler.onPaymentFailed((context) => {
|
|
1500
|
+
events.push(`failed:${context.challenge.id}`)
|
|
1501
|
+
failedRequestAmount = context.request.amount
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
await expect(
|
|
1505
|
+
handler.verifyCredential(
|
|
1506
|
+
Credential.from({
|
|
1507
|
+
challenge: Challenge.from({
|
|
1508
|
+
id: 'forged-id',
|
|
1509
|
+
intent: 'charge',
|
|
1510
|
+
method: 'mock',
|
|
1511
|
+
realm,
|
|
1512
|
+
request: { amount: { $ne: '1000' }, currency: asset },
|
|
1513
|
+
}),
|
|
1514
|
+
payload: { token: 'valid' },
|
|
1515
|
+
}),
|
|
1516
|
+
),
|
|
1517
|
+
).rejects.toThrow(Errors.InvalidChallengeError)
|
|
1518
|
+
expect(events).toEqual(['failed:forged-id'])
|
|
1519
|
+
expect(failedRequestAmount).toEqual({ $ne: '1000' })
|
|
1520
|
+
})
|
|
1521
|
+
|
|
1522
|
+
test('emits payment failure when credential-bearing request hook rejects', async () => {
|
|
1523
|
+
const events: string[] = []
|
|
1524
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1525
|
+
request({ credential, request }) {
|
|
1526
|
+
if (credential) throw new Errors.VerificationFailedError({ reason: 'request rejected' })
|
|
1527
|
+
return request
|
|
1528
|
+
},
|
|
1529
|
+
async verify() {
|
|
1530
|
+
return receipt()
|
|
1531
|
+
},
|
|
1532
|
+
})
|
|
1533
|
+
const handler = Mppx.create({
|
|
1534
|
+
methods: [serverMethod],
|
|
1535
|
+
realm,
|
|
1536
|
+
secretKey,
|
|
1537
|
+
})
|
|
1538
|
+
handler.onPaymentFailed((context) => {
|
|
1539
|
+
events.push(`failed:${context.error.name}:${context.submittedChallenge?.id}`)
|
|
1540
|
+
})
|
|
1541
|
+
|
|
1542
|
+
const handle = handler.charge(options())
|
|
1543
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1544
|
+
expect(first.status).toBe(402)
|
|
1545
|
+
if (first.status !== 402) throw new Error()
|
|
1546
|
+
const challenge = Challenge.fromResponse(first.challenge)
|
|
1547
|
+
|
|
1548
|
+
const result = await handle(
|
|
1549
|
+
new Request('https://example.com/resource', {
|
|
1550
|
+
headers: {
|
|
1551
|
+
Authorization: Credential.serialize(
|
|
1552
|
+
Credential.from({
|
|
1553
|
+
challenge,
|
|
1554
|
+
payload: { token: 'valid' },
|
|
1555
|
+
}),
|
|
1556
|
+
),
|
|
1557
|
+
},
|
|
1558
|
+
}),
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
expect(result.status).toBe(402)
|
|
1562
|
+
expect(events).toEqual([`failed:VerificationFailedError:${challenge.id}`])
|
|
1563
|
+
})
|
|
1564
|
+
|
|
1565
|
+
test('does not emit challenge events for service worker requests', async () => {
|
|
1566
|
+
const events: string[] = []
|
|
1567
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1568
|
+
html: {
|
|
1569
|
+
config: {},
|
|
1570
|
+
content: '<script src="/bundle.js"></script>',
|
|
1571
|
+
formatAmount: (request: Record<string, unknown>) => `$${request.amount}`,
|
|
1572
|
+
text: undefined,
|
|
1573
|
+
theme: undefined,
|
|
1574
|
+
},
|
|
1575
|
+
async verify() {
|
|
1576
|
+
return receipt()
|
|
1577
|
+
},
|
|
1578
|
+
})
|
|
1579
|
+
const handler = Mppx.create({
|
|
1580
|
+
methods: [serverMethod],
|
|
1581
|
+
realm,
|
|
1582
|
+
secretKey,
|
|
1583
|
+
})
|
|
1584
|
+
handler.onChallengeCreated(() => {
|
|
1585
|
+
events.push('challenge')
|
|
1586
|
+
})
|
|
1587
|
+
|
|
1588
|
+
const result = await handler.charge(options())(
|
|
1589
|
+
new Request('https://example.com/resource?__mppx_worker'),
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
expect(result.status).toBe(402)
|
|
1593
|
+
if (result.status !== 402) throw new Error()
|
|
1594
|
+
expect(result.challenge.status).toBe(200)
|
|
1595
|
+
expect(events).toEqual([])
|
|
1596
|
+
})
|
|
1597
|
+
|
|
1598
|
+
test('rejects reserved lifecycle helper collisions', () => {
|
|
1599
|
+
const collidingMethod = Method.toServer(
|
|
1600
|
+
Method.from({
|
|
1601
|
+
name: 'mock',
|
|
1602
|
+
intent: 'onPaymentSuccess',
|
|
1603
|
+
schema: {
|
|
1604
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
1605
|
+
request: z.object({ amount: z.string() }),
|
|
1606
|
+
},
|
|
1607
|
+
}),
|
|
1608
|
+
{
|
|
1609
|
+
async verify() {
|
|
1610
|
+
return receipt()
|
|
1611
|
+
},
|
|
1612
|
+
},
|
|
1613
|
+
)
|
|
1614
|
+
|
|
1615
|
+
expect(() =>
|
|
1616
|
+
Mppx.create({
|
|
1617
|
+
methods: [collidingMethod],
|
|
1618
|
+
realm,
|
|
1619
|
+
secretKey,
|
|
1620
|
+
}),
|
|
1621
|
+
).toThrow('reserved Mppx property')
|
|
1622
|
+
})
|
|
1623
|
+
|
|
1624
|
+
test('rejects duplicated reserved intent collisions', () => {
|
|
1625
|
+
const createCollidingMethod = (name: string) =>
|
|
1626
|
+
Method.toServer(
|
|
1627
|
+
Method.from({
|
|
1628
|
+
name,
|
|
1629
|
+
intent: 'onPaymentSuccess',
|
|
1630
|
+
schema: {
|
|
1631
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
1632
|
+
request: z.object({ amount: z.string() }),
|
|
1633
|
+
},
|
|
1634
|
+
}),
|
|
1635
|
+
{
|
|
1636
|
+
async verify() {
|
|
1637
|
+
return receipt()
|
|
1638
|
+
},
|
|
1639
|
+
},
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
expect(() =>
|
|
1643
|
+
Mppx.create({
|
|
1644
|
+
methods: [createCollidingMethod('alpha'), createCollidingMethod('beta')],
|
|
1645
|
+
realm,
|
|
1646
|
+
secretKey,
|
|
1647
|
+
}),
|
|
1648
|
+
).toThrow('reserved Mppx property')
|
|
1649
|
+
})
|
|
1650
|
+
|
|
1651
|
+
test('server event request inputs are bodyless snapshots', async () => {
|
|
1652
|
+
let eventInput: Request | undefined
|
|
1653
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1654
|
+
async verify() {
|
|
1655
|
+
return receipt()
|
|
1656
|
+
},
|
|
1657
|
+
})
|
|
1658
|
+
const handler = Mppx.create({
|
|
1659
|
+
methods: [serverMethod],
|
|
1660
|
+
realm,
|
|
1661
|
+
secretKey,
|
|
1662
|
+
})
|
|
1663
|
+
handler.onChallengeCreated((context) => {
|
|
1664
|
+
eventInput = context.input
|
|
1665
|
+
})
|
|
1666
|
+
|
|
1667
|
+
const original = new Request('https://example.com/resource', {
|
|
1668
|
+
body: 'paid upload',
|
|
1669
|
+
method: 'POST',
|
|
1670
|
+
})
|
|
1671
|
+
const result = await handler.charge(options())(original)
|
|
1672
|
+
|
|
1673
|
+
expect(result.status).toBe(402)
|
|
1674
|
+
expect(eventInput).toBeInstanceOf(Request)
|
|
1675
|
+
expect(eventInput?.body).toBeNull()
|
|
1676
|
+
await expect(original.text()).resolves.toBe('paid upload')
|
|
1677
|
+
})
|
|
1678
|
+
|
|
1679
|
+
test('payment success events expose transformed credential and request values', async () => {
|
|
1680
|
+
const transformed = Method.from({
|
|
1681
|
+
name: 'transformed',
|
|
1682
|
+
intent: 'charge',
|
|
1683
|
+
schema: {
|
|
1684
|
+
credential: {
|
|
1685
|
+
payload: z.pipe(
|
|
1686
|
+
z.object({ admin: z.string() }),
|
|
1687
|
+
z.transform(({ admin }) => ({ admin: admin === 'true' })),
|
|
1688
|
+
),
|
|
1689
|
+
},
|
|
1690
|
+
request: z.pipe(
|
|
1691
|
+
z.object({
|
|
1692
|
+
amount: z.string(),
|
|
1693
|
+
currency: z.string(),
|
|
1694
|
+
decimals: z.number(),
|
|
1695
|
+
recipient: z.string(),
|
|
1696
|
+
}),
|
|
1697
|
+
z.transform(({ amount, currency, decimals, recipient }) => ({
|
|
1698
|
+
amount: String(Number(amount) * 10 ** decimals),
|
|
1699
|
+
currency,
|
|
1700
|
+
recipient,
|
|
1701
|
+
})),
|
|
1702
|
+
),
|
|
1703
|
+
},
|
|
1704
|
+
})
|
|
1705
|
+
const seen: Record<string, unknown> = {}
|
|
1706
|
+
const serverMethod = Method.toServer(transformed, {
|
|
1707
|
+
async verify({ credential, envelope, request }) {
|
|
1708
|
+
seen.verifyAdmin = credential.payload.admin
|
|
1709
|
+
seen.verifyEnvelopeAmount = envelope?.request.amount
|
|
1710
|
+
seen.verifyRequestAmount = request.amount
|
|
1711
|
+
return receipt('tx-transformed')
|
|
1712
|
+
},
|
|
1713
|
+
})
|
|
1714
|
+
const handler = Mppx.create({
|
|
1715
|
+
methods: [serverMethod],
|
|
1716
|
+
realm,
|
|
1717
|
+
secretKey,
|
|
1718
|
+
})
|
|
1719
|
+
handler.onPaymentSuccess((context) => {
|
|
1720
|
+
seen.eventAdmin = context.credential?.payload.admin
|
|
1721
|
+
seen.eventEnvelopeAmount = context.envelope?.request.amount
|
|
1722
|
+
seen.eventRequestAmount = context.request.amount
|
|
1723
|
+
})
|
|
1724
|
+
const handle = handler.charge({
|
|
1725
|
+
amount: '25',
|
|
1726
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1727
|
+
decimals: 2,
|
|
1728
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1729
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1730
|
+
})
|
|
1731
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1732
|
+
expect(first.status).toBe(402)
|
|
1733
|
+
if (first.status !== 402) throw new Error()
|
|
1734
|
+
|
|
1735
|
+
const paid = await handle(
|
|
1736
|
+
new Request('https://example.com/resource', {
|
|
1737
|
+
headers: {
|
|
1738
|
+
Authorization: Credential.serialize(
|
|
1739
|
+
Credential.from({
|
|
1740
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1741
|
+
payload: { admin: 'false' },
|
|
1742
|
+
}),
|
|
1743
|
+
),
|
|
1744
|
+
},
|
|
1745
|
+
}),
|
|
1746
|
+
)
|
|
1747
|
+
|
|
1748
|
+
expect(paid.status).toBe(200)
|
|
1749
|
+
expect(seen).toMatchObject({
|
|
1750
|
+
eventAdmin: false,
|
|
1751
|
+
eventEnvelopeAmount: '2500',
|
|
1752
|
+
eventRequestAmount: '2500',
|
|
1753
|
+
verifyAdmin: false,
|
|
1754
|
+
verifyEnvelopeAmount: '2500',
|
|
1755
|
+
verifyRequestAmount: '25',
|
|
1756
|
+
})
|
|
1757
|
+
})
|
|
1758
|
+
|
|
1759
|
+
test('snapshots payment success payloads before listeners run', async () => {
|
|
1760
|
+
let mutationBlocked = false
|
|
1761
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1762
|
+
async verify() {
|
|
1763
|
+
return receipt('tx-original')
|
|
1764
|
+
},
|
|
1765
|
+
})
|
|
1766
|
+
const handler = Mppx.create({
|
|
1767
|
+
methods: [serverMethod],
|
|
1768
|
+
realm,
|
|
1769
|
+
secretKey,
|
|
1770
|
+
})
|
|
1771
|
+
handler.onPaymentSuccess((context) => {
|
|
1772
|
+
try {
|
|
1773
|
+
;(context.receipt as { reference: string }).reference = 'tx-mutated'
|
|
1774
|
+
} catch {
|
|
1775
|
+
mutationBlocked = true
|
|
1776
|
+
}
|
|
1777
|
+
})
|
|
1778
|
+
const handle = handler.charge(options())
|
|
1779
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1780
|
+
expect(first.status).toBe(402)
|
|
1781
|
+
if (first.status !== 402) throw new Error()
|
|
1782
|
+
|
|
1783
|
+
const paid = await handle(
|
|
1784
|
+
new Request('https://example.com/resource', {
|
|
1785
|
+
headers: {
|
|
1786
|
+
Authorization: Credential.serialize(
|
|
1787
|
+
Credential.from({
|
|
1788
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1789
|
+
payload: { token: 'valid' },
|
|
1790
|
+
}),
|
|
1791
|
+
),
|
|
1792
|
+
},
|
|
1793
|
+
}),
|
|
1794
|
+
)
|
|
1795
|
+
expect(paid.status).toBe(200)
|
|
1796
|
+
if (paid.status !== 200) throw new Error()
|
|
1797
|
+
|
|
1798
|
+
const response = paid.withReceipt(Response.json({ ok: true }))
|
|
1799
|
+
expect(mutationBlocked).toBe(true)
|
|
1800
|
+
expect(Receipt.fromResponse(response).reference).toBe('tx-original')
|
|
1801
|
+
})
|
|
1802
|
+
})
|
|
1803
|
+
|
|
917
1804
|
describe('compose', () => {
|
|
918
1805
|
const mockChargeA = Method.from({
|
|
919
1806
|
name: 'alpha',
|
|
@@ -3679,11 +4566,15 @@ describe('verifyCredential', () => {
|
|
|
3679
4566
|
})
|
|
3680
4567
|
|
|
3681
4568
|
test('rejects credential for unregistered method/intent', async () => {
|
|
4569
|
+
const events: string[] = []
|
|
3682
4570
|
const mppx = Mppx.create({
|
|
3683
4571
|
methods: [alphaChargeServer],
|
|
3684
4572
|
realm,
|
|
3685
4573
|
secretKey,
|
|
3686
4574
|
})
|
|
4575
|
+
mppx.onPaymentFailed((context) => {
|
|
4576
|
+
events.push(`${context.method.name}/${context.method.intent}:${context.error.name}`)
|
|
4577
|
+
})
|
|
3687
4578
|
|
|
3688
4579
|
// Forge a challenge for an unregistered method using the same secret
|
|
3689
4580
|
const challenge = Challenge.from({
|
|
@@ -3702,6 +4593,7 @@ describe('verifyCredential', () => {
|
|
|
3702
4593
|
await expect(mppx.verifyCredential(credential)).rejects.toThrow(
|
|
3703
4594
|
'no registered method for unknown/charge',
|
|
3704
4595
|
)
|
|
4596
|
+
expect(events).toEqual(['unknown/charge:InvalidChallengeError'])
|
|
3705
4597
|
})
|
|
3706
4598
|
|
|
3707
4599
|
test('rejects malformed credential string', async () => {
|