stable-ci 0.1.3 → 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 +103 -2
- 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
|
@@ -129,6 +129,16 @@ PASS late_payment
|
|
|
129
129
|
|
|
130
130
|
A failed scenario causes a non-zero exit code, so it can fail CI.
|
|
131
131
|
|
|
132
|
+
## Working example
|
|
133
|
+
|
|
134
|
+
A complete external integration example is available here:
|
|
135
|
+
|
|
136
|
+
https://github.com/saldfsdk/stable-ci-example
|
|
137
|
+
|
|
138
|
+
The example repository installs `stable-ci` from npm, starts a test payment application, and runs `saldfsdk/stable-ci@v0.1.3` as a GitHub Action from a separate repository.
|
|
139
|
+
|
|
140
|
+
Its CI verifies the full external integration path and produces a JUnit report.
|
|
141
|
+
|
|
132
142
|
## Expected outcomes
|
|
133
143
|
|
|
134
144
|
Different applications may intentionally handle payment exceptions differently.
|
|
@@ -161,9 +171,9 @@ This repository includes a composite GitHub Action.
|
|
|
161
171
|
|
|
162
172
|
```yaml
|
|
163
173
|
steps:
|
|
164
|
-
- uses: actions/checkout@
|
|
174
|
+
- uses: actions/checkout@v7
|
|
165
175
|
|
|
166
|
-
- uses: saldfsdk/stable-ci@v0.
|
|
176
|
+
- uses: saldfsdk/stable-ci@v0.2.0
|
|
167
177
|
with:
|
|
168
178
|
config: stable-ci.yml
|
|
169
179
|
junit: reports/stable-ci.xml
|
|
@@ -212,6 +222,97 @@ Current scenarios include:
|
|
|
212
222
|
- `overpayment`
|
|
213
223
|
- `late_payment`
|
|
214
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
|
+
|
|
215
316
|
## Provider support
|
|
216
317
|
|
|
217
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
|
+
}
|