stable-ci 0.1.4 → 0.2.0
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/README.md +92 -1
- package/dist/adapters/configured-http.js +25 -10
- package/dist/config.js +86 -5
- package/dist/providers/bvnk.js +1 -0
- package/dist/providers/custom.js +82 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -173,7 +173,7 @@ This repository includes a composite GitHub Action.
|
|
|
173
173
|
steps:
|
|
174
174
|
- uses: actions/checkout@v7
|
|
175
175
|
|
|
176
|
-
- uses: saldfsdk/stable-ci@v0.
|
|
176
|
+
- uses: saldfsdk/stable-ci@v0.2.0
|
|
177
177
|
with:
|
|
178
178
|
config: stable-ci.yml
|
|
179
179
|
junit: reports/stable-ci.xml
|
|
@@ -222,6 +222,97 @@ Current scenarios include:
|
|
|
222
222
|
- `overpayment`
|
|
223
223
|
- `late_payment`
|
|
224
224
|
|
|
225
|
+
## Custom webhook providers
|
|
226
|
+
|
|
227
|
+
`stable-ci` can test payment integrations that use providers without a built-in adapter.
|
|
228
|
+
|
|
229
|
+
Use JSON webhook fixtures to describe the provider payloads that your application already accepts.
|
|
230
|
+
|
|
231
|
+
Example `stable-ci.yml`:
|
|
232
|
+
|
|
233
|
+
```yaml
|
|
234
|
+
provider: custom
|
|
235
|
+
|
|
236
|
+
webhookSecret: your-test-webhook-secret
|
|
237
|
+
|
|
238
|
+
customProvider:
|
|
239
|
+
name: onswitch-like
|
|
240
|
+
|
|
241
|
+
fixtures:
|
|
242
|
+
pending: fixtures/pending.json
|
|
243
|
+
completed: fixtures/completed.json
|
|
244
|
+
underpaid: fixtures/underpaid.json
|
|
245
|
+
expired: fixtures/expired.json
|
|
246
|
+
|
|
247
|
+
signature:
|
|
248
|
+
header: x-switch-signature
|
|
249
|
+
algorithm: sha256
|
|
250
|
+
encoding: base64
|
|
251
|
+
|
|
252
|
+
target:
|
|
253
|
+
name: payment-app
|
|
254
|
+
baseUrl: http://127.0.0.1:4310
|
|
255
|
+
endpoints:
|
|
256
|
+
reset: /reset
|
|
257
|
+
webhook: /webhook
|
|
258
|
+
state: /state
|
|
259
|
+
reconcile: /reconcile
|
|
260
|
+
|
|
261
|
+
payment:
|
|
262
|
+
id: pay_test_001
|
|
263
|
+
amount: 100
|
|
264
|
+
asset: USDC
|
|
265
|
+
|
|
266
|
+
scenarios:
|
|
267
|
+
- duplicate_webhook
|
|
268
|
+
- out_of_order_webhook
|
|
269
|
+
- missing_webhook
|
|
270
|
+
- invalid_signature
|
|
271
|
+
- underpayment
|
|
272
|
+
- overpayment
|
|
273
|
+
- late_payment
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
A fixture can use placeholders:
|
|
277
|
+
|
|
278
|
+
```json
|
|
279
|
+
{
|
|
280
|
+
"id": "{{eventId}}",
|
|
281
|
+
"paymentId": "{{paymentId}}",
|
|
282
|
+
"status": "{{status}}",
|
|
283
|
+
"amount": "{{amount}}",
|
|
284
|
+
"actualAmount": "{{actualAmount}}",
|
|
285
|
+
"asset": "{{asset}}",
|
|
286
|
+
"eventType": "{{eventType}}"
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Available placeholders:
|
|
291
|
+
|
|
292
|
+
- `{{eventId}}`
|
|
293
|
+
- `{{paymentId}}`
|
|
294
|
+
- `{{status}}`
|
|
295
|
+
- `{{amount}}`
|
|
296
|
+
- `{{actualAmount}}`
|
|
297
|
+
- `{{asset}}`
|
|
298
|
+
- `{{eventType}}`
|
|
299
|
+
|
|
300
|
+
When a placeholder is the entire JSON string value, numbers remain numbers instead of being converted to strings.
|
|
301
|
+
|
|
302
|
+
Fixture paths are resolved relative to `stable-ci.yml`.
|
|
303
|
+
|
|
304
|
+
Custom headers may also contain placeholders:
|
|
305
|
+
|
|
306
|
+
```yaml
|
|
307
|
+
customProvider:
|
|
308
|
+
headers:
|
|
309
|
+
x-payment-id: "{{paymentId}}"
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
For signed webhooks, `stable-ci` signs the final rendered raw JSON body. The signature header name is configurable, so providers using headers such as `x-switch-signature` can be tested without adding provider-specific code to `stable-ci`.
|
|
313
|
+
|
|
314
|
+
The target application still exposes the stable-ci test observer endpoints (`reset`, `state`, and optionally `reconcile`). These endpoints are intended for test and CI environments only.
|
|
315
|
+
|
|
225
316
|
## Provider support
|
|
226
317
|
|
|
227
318
|
### BVNK
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { createBvnkProvider } from '../providers/bvnk.js';
|
|
3
3
|
import { createGenericProvider } from '../providers/generic.js';
|
|
4
|
+
import { createCustomProvider } from '../providers/custom.js';
|
|
4
5
|
const stateSchema = z.object({
|
|
5
6
|
applicationStatus: z.enum([
|
|
6
7
|
'none',
|
|
@@ -20,6 +21,19 @@ function getProvider(config) {
|
|
|
20
21
|
if (config.provider === 'bvnk') {
|
|
21
22
|
return createBvnkProvider(config.webhookSecret ?? 'stable-ci-local-secret');
|
|
22
23
|
}
|
|
24
|
+
if (config.provider === 'custom') {
|
|
25
|
+
const custom = config.customProvider;
|
|
26
|
+
if (!custom) {
|
|
27
|
+
throw new Error('Custom provider configuration is missing.');
|
|
28
|
+
}
|
|
29
|
+
return createCustomProvider({
|
|
30
|
+
name: custom.name,
|
|
31
|
+
fixtures: custom.fixtures,
|
|
32
|
+
headers: custom.headers,
|
|
33
|
+
signature: custom.signature,
|
|
34
|
+
webhookSecret: config.webhookSecret,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
23
37
|
return createGenericProvider();
|
|
24
38
|
}
|
|
25
39
|
async function postJson(baseUrl, path, body = {}) {
|
|
@@ -63,12 +77,16 @@ async function sendInvalidSignatureWebhook(config, provider) {
|
|
|
63
77
|
amount: config.payment.amount,
|
|
64
78
|
asset: config.payment.asset,
|
|
65
79
|
});
|
|
80
|
+
const signatureHeader = provider.signatureHeader;
|
|
81
|
+
if (!signatureHeader) {
|
|
82
|
+
throw new Error('invalid_signature requires a provider with a configured signature header.');
|
|
83
|
+
}
|
|
66
84
|
const response = await fetch(config.target.baseUrl +
|
|
67
85
|
config.target.endpoints.webhook, {
|
|
68
86
|
method: 'POST',
|
|
69
87
|
headers: {
|
|
70
88
|
...rendered.headers,
|
|
71
|
-
|
|
89
|
+
[signatureHeader]: 'stable-ci-invalid-signature',
|
|
72
90
|
},
|
|
73
91
|
body: rendered.body,
|
|
74
92
|
});
|
|
@@ -115,33 +133,30 @@ export function createConfiguredHttpAdapter(config) {
|
|
|
115
133
|
}
|
|
116
134
|
break;
|
|
117
135
|
case 'invalid_signature':
|
|
118
|
-
if (provider.name !== 'bvnk') {
|
|
119
|
-
throw new Error('invalid_signature currently requires the BVNK provider.');
|
|
120
|
-
}
|
|
121
136
|
webhookAccepted =
|
|
122
137
|
await sendInvalidSignatureWebhook(config, provider);
|
|
123
138
|
providerStatus = 'failed';
|
|
124
139
|
chainStatus = 'not_broadcast';
|
|
125
140
|
break;
|
|
126
141
|
case 'underpayment':
|
|
127
|
-
if (provider
|
|
128
|
-
throw new Error('underpayment
|
|
142
|
+
if (config.provider === 'generic') {
|
|
143
|
+
throw new Error('underpayment requires the BVNK or custom provider.');
|
|
129
144
|
}
|
|
130
145
|
receivedAmount = payment.amount * 0.6;
|
|
131
146
|
providerStatus = 'underpaid';
|
|
132
147
|
await sendWebhook(config, provider, 'evt_underpayment_1', 'underpaid', receivedAmount, 'transactionConfirmed');
|
|
133
148
|
break;
|
|
134
149
|
case 'overpayment':
|
|
135
|
-
if (provider
|
|
136
|
-
throw new Error('overpayment
|
|
150
|
+
if (config.provider === 'generic') {
|
|
151
|
+
throw new Error('overpayment requires the BVNK or custom provider.');
|
|
137
152
|
}
|
|
138
153
|
receivedAmount = payment.amount * 1.4;
|
|
139
154
|
providerStatus = 'completed';
|
|
140
155
|
await sendWebhook(config, provider, 'evt_overpayment_1', 'completed', receivedAmount, 'transactionConfirmed');
|
|
141
156
|
break;
|
|
142
157
|
case 'late_payment':
|
|
143
|
-
if (provider
|
|
144
|
-
throw new Error('late_payment
|
|
158
|
+
if (config.provider === 'generic') {
|
|
159
|
+
throw new Error('late_payment requires the BVNK or custom provider.');
|
|
145
160
|
}
|
|
146
161
|
receivedAmount = payment.amount;
|
|
147
162
|
providerStatus = 'expired';
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { dirname, resolve, } from 'node:path';
|
|
2
3
|
import { parse } from 'yaml';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
const scenarioSchema = z.enum([
|
|
@@ -32,9 +33,31 @@ const expectedOutcomeSchema = z.object({
|
|
|
32
33
|
retryAttempts: z.number().int().nonnegative().optional(),
|
|
33
34
|
webhookAccepted: z.boolean().optional(),
|
|
34
35
|
});
|
|
36
|
+
const fixtureSchema = z.object({
|
|
37
|
+
pending: z.string().min(1).optional(),
|
|
38
|
+
completed: z.string().min(1).optional(),
|
|
39
|
+
failed: z.string().min(1).optional(),
|
|
40
|
+
underpaid: z.string().min(1).optional(),
|
|
41
|
+
expired: z.string().min(1).optional(),
|
|
42
|
+
});
|
|
43
|
+
const customProviderSchema = z.object({
|
|
44
|
+
name: z.string().min(1).default('custom'),
|
|
45
|
+
fixtures: fixtureSchema,
|
|
46
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
47
|
+
signature: z.object({
|
|
48
|
+
header: z.string().min(1),
|
|
49
|
+
algorithm: z.literal('sha256').default('sha256'),
|
|
50
|
+
encoding: z.enum(['base64', 'hex']).default('base64'),
|
|
51
|
+
}).optional(),
|
|
52
|
+
});
|
|
35
53
|
const configSchema = z.object({
|
|
36
|
-
provider: z.enum([
|
|
54
|
+
provider: z.enum([
|
|
55
|
+
'generic',
|
|
56
|
+
'bvnk',
|
|
57
|
+
'custom',
|
|
58
|
+
]).default('generic'),
|
|
37
59
|
webhookSecret: z.string().min(1).optional(),
|
|
60
|
+
customProvider: customProviderSchema.optional(),
|
|
38
61
|
target: z.object({
|
|
39
62
|
name: z.string().min(1),
|
|
40
63
|
baseUrl: z.string().url(),
|
|
@@ -53,14 +76,72 @@ const configSchema = z.object({
|
|
|
53
76
|
scenarios: z.array(scenarioSchema).min(1),
|
|
54
77
|
expectations: z.record(z.string(), expectedOutcomeSchema).optional(),
|
|
55
78
|
});
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
79
|
+
function resolveFixtures(fixtures, configDir) {
|
|
80
|
+
return Object.fromEntries(Object.entries(fixtures).map(([status, fixturePath]) => [
|
|
81
|
+
status,
|
|
82
|
+
resolve(configDir, fixturePath),
|
|
83
|
+
]));
|
|
84
|
+
}
|
|
85
|
+
export function loadConfig(configPath) {
|
|
86
|
+
if (!fs.existsSync(configPath)) {
|
|
87
|
+
throw new Error('Config file not found: ' + configPath);
|
|
59
88
|
}
|
|
60
|
-
const raw = fs.readFileSync(
|
|
89
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
61
90
|
const parsed = configSchema.parse(parse(raw));
|
|
91
|
+
const configDir = dirname(resolve(configPath));
|
|
92
|
+
const customProvider = parsed.customProvider
|
|
93
|
+
? {
|
|
94
|
+
...parsed.customProvider,
|
|
95
|
+
fixtures: resolveFixtures(parsed.customProvider.fixtures, configDir),
|
|
96
|
+
}
|
|
97
|
+
: undefined;
|
|
98
|
+
if (parsed.provider === 'custom' &&
|
|
99
|
+
!customProvider) {
|
|
100
|
+
throw new Error('provider custom requires customProvider configuration.');
|
|
101
|
+
}
|
|
102
|
+
if (parsed.provider === 'custom' &&
|
|
103
|
+
customProvider) {
|
|
104
|
+
const requiredFixtures = new Set();
|
|
105
|
+
for (const scenario of parsed.scenarios) {
|
|
106
|
+
switch (scenario) {
|
|
107
|
+
case 'duplicate_webhook':
|
|
108
|
+
case 'overpayment':
|
|
109
|
+
case 'invalid_signature':
|
|
110
|
+
requiredFixtures.add('completed');
|
|
111
|
+
break;
|
|
112
|
+
case 'out_of_order_webhook':
|
|
113
|
+
requiredFixtures.add('completed');
|
|
114
|
+
requiredFixtures.add('pending');
|
|
115
|
+
break;
|
|
116
|
+
case 'underpayment':
|
|
117
|
+
requiredFixtures.add('underpaid');
|
|
118
|
+
break;
|
|
119
|
+
case 'late_payment':
|
|
120
|
+
requiredFixtures.add('expired');
|
|
121
|
+
break;
|
|
122
|
+
case 'missing_webhook':
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const status of requiredFixtures) {
|
|
127
|
+
if (!customProvider.fixtures[status]) {
|
|
128
|
+
throw new Error('Custom provider is missing the ' +
|
|
129
|
+
status +
|
|
130
|
+
' webhook fixture required by the configured scenarios.');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (customProvider.signature &&
|
|
134
|
+
!parsed.webhookSecret) {
|
|
135
|
+
throw new Error('customProvider.signature requires webhookSecret.');
|
|
136
|
+
}
|
|
137
|
+
if (parsed.scenarios.includes('invalid_signature') &&
|
|
138
|
+
!customProvider.signature) {
|
|
139
|
+
throw new Error('invalid_signature requires customProvider.signature.');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
62
142
|
return {
|
|
63
143
|
...parsed,
|
|
144
|
+
customProvider,
|
|
64
145
|
scenarios: parsed.scenarios,
|
|
65
146
|
expectations: parsed.expectations,
|
|
66
147
|
};
|
package/dist/providers/bvnk.js
CHANGED
|
@@ -15,6 +15,7 @@ function toBvnkStatus(status) {
|
|
|
15
15
|
export function createBvnkProvider(webhookSecret = 'stable-ci-local-secret') {
|
|
16
16
|
return {
|
|
17
17
|
name: 'bvnk',
|
|
18
|
+
signatureHeader: 'x-signature',
|
|
18
19
|
render(event) {
|
|
19
20
|
const eventType = event.eventType ?? 'statusChanged';
|
|
20
21
|
const actualAmount = event.actualAmount ?? event.amount;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
function templateValues(event) {
|
|
4
|
+
return {
|
|
5
|
+
eventId: event.eventId,
|
|
6
|
+
paymentId: event.paymentId,
|
|
7
|
+
status: event.status,
|
|
8
|
+
amount: event.amount,
|
|
9
|
+
actualAmount: event.actualAmount ?? event.amount,
|
|
10
|
+
asset: event.asset,
|
|
11
|
+
eventType: event.eventType ?? null,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function renderString(value, values) {
|
|
15
|
+
const exact = value.match(/^\{\{(eventId|paymentId|status|amount|actualAmount|asset|eventType)\}\}$/);
|
|
16
|
+
if (exact) {
|
|
17
|
+
return values[exact[1]];
|
|
18
|
+
}
|
|
19
|
+
return value.replace(/\{\{(eventId|paymentId|status|amount|actualAmount|asset|eventType)\}\}/g, (_match, key) => String(values[key] ?? ''));
|
|
20
|
+
}
|
|
21
|
+
function renderTemplateValue(value, values) {
|
|
22
|
+
if (typeof value === 'string') {
|
|
23
|
+
return renderString(value, values);
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return value.map((item) => renderTemplateValue(item, values));
|
|
27
|
+
}
|
|
28
|
+
if (typeof value === 'object' &&
|
|
29
|
+
value !== null) {
|
|
30
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
31
|
+
key,
|
|
32
|
+
renderTemplateValue(item, values),
|
|
33
|
+
]));
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
function renderBodyTemplate(template, event) {
|
|
38
|
+
const parsed = JSON.parse(template);
|
|
39
|
+
const rendered = renderTemplateValue(parsed, templateValues(event));
|
|
40
|
+
return JSON.stringify(rendered);
|
|
41
|
+
}
|
|
42
|
+
function renderHeaderTemplate(template, event) {
|
|
43
|
+
const values = templateValues(event);
|
|
44
|
+
return template.replace(/\{\{(eventId|paymentId|status|amount|actualAmount|asset|eventType)\}\}/g, (_match, key) => String(values[key] ?? ''));
|
|
45
|
+
}
|
|
46
|
+
export function createCustomProvider(options) {
|
|
47
|
+
if (options.signature &&
|
|
48
|
+
!options.webhookSecret) {
|
|
49
|
+
throw new Error('custom provider signature requires webhookSecret.');
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
name: options.name,
|
|
53
|
+
signatureHeader: options.signature?.header,
|
|
54
|
+
render(event) {
|
|
55
|
+
const fixturePath = options.fixtures[event.status];
|
|
56
|
+
if (!fixturePath) {
|
|
57
|
+
throw new Error('No custom webhook fixture configured for status: ' +
|
|
58
|
+
event.status);
|
|
59
|
+
}
|
|
60
|
+
const template = readFileSync(fixturePath, 'utf8');
|
|
61
|
+
const body = renderBodyTemplate(template, event);
|
|
62
|
+
const headers = {
|
|
63
|
+
'content-type': 'application/json',
|
|
64
|
+
};
|
|
65
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
66
|
+
headers[name] =
|
|
67
|
+
renderHeaderTemplate(value, event);
|
|
68
|
+
}
|
|
69
|
+
if (options.signature) {
|
|
70
|
+
const signature = createHmac(options.signature.algorithm, options.webhookSecret)
|
|
71
|
+
.update(body, 'utf8')
|
|
72
|
+
.digest(options.signature.encoding);
|
|
73
|
+
headers[options.signature.header] =
|
|
74
|
+
signature;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
body,
|
|
78
|
+
headers,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|