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 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@v4
174
+ - uses: actions/checkout@v7
165
175
 
166
- - uses: saldfsdk/stable-ci@v0.1.1
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
- 'x-signature': 'invalid-signature',
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.name !== 'bvnk') {
128
- throw new Error('underpayment currently requires the BVNK provider.');
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.name !== 'bvnk') {
136
- throw new Error('overpayment currently requires the BVNK provider.');
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.name !== 'bvnk') {
144
- throw new Error('late_payment currently requires the BVNK provider.');
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(['generic', 'bvnk']).default('generic'),
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
- export function loadConfig(path) {
57
- if (!fs.existsSync(path)) {
58
- throw new Error('Config file not found: ' + path);
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(path, 'utf8');
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
  };
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stable-ci",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Reliability CI for stablecoin payment integrations",
5
5
  "scripts": {
6
6
  "test": "vitest run",