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 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.1.3
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
- '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.4",
3
+ "version": "0.2.0",
4
4
  "description": "Reliability CI for stablecoin payment integrations",
5
5
  "scripts": {
6
6
  "test": "vitest run",