stable-ci 0.1.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/LICENSE +21 -0
- package/README.md +346 -0
- package/dist/adapters/configured-http.js +173 -0
- package/dist/adapters/demo.js +150 -0
- package/dist/adapters/http-demo.js +232 -0
- package/dist/config.js +67 -0
- package/dist/core/demo-payment-app.js +254 -0
- package/dist/core/runner.js +16 -0
- package/dist/index.js +183 -0
- package/dist/invariants/builtins.js +138 -0
- package/dist/providers/bvnk.js +60 -0
- package/dist/providers/generic.js +13 -0
- package/dist/providers/types.js +1 -0
- package/dist/reporters/junit.js +65 -0
- package/dist/scenarios/builtins.js +106 -0
- package/dist/types.js +1 -0
- package/package.json +57 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { createDemoAdapter } from './adapters/demo.js';
|
|
4
|
+
import { createHttpDemoAdapter } from './adapters/http-demo.js';
|
|
5
|
+
import { createConfiguredHttpAdapter } from './adapters/configured-http.js';
|
|
6
|
+
import { startDemoPaymentApp } from './core/demo-payment-app.js';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { runSuite } from './core/runner.js';
|
|
9
|
+
import { writeJUnit } from './reporters/junit.js';
|
|
10
|
+
import { scenarios } from './scenarios/builtins.js';
|
|
11
|
+
const program = new Command();
|
|
12
|
+
function printResults(adapterName, results) {
|
|
13
|
+
console.log('Stablecoin Reliability CI');
|
|
14
|
+
console.log('=========================');
|
|
15
|
+
console.log('Adapter: ' + adapterName);
|
|
16
|
+
console.log('');
|
|
17
|
+
for (const result of results) {
|
|
18
|
+
console.log((result.passed ? 'PASS' : 'FAIL') +
|
|
19
|
+
' ' +
|
|
20
|
+
result.scenario.name);
|
|
21
|
+
for (const invariant of result.invariants.filter((x) => !x.passed)) {
|
|
22
|
+
console.log(' ' + invariant.name);
|
|
23
|
+
if (invariant.message) {
|
|
24
|
+
console.log(' ' + invariant.message);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const passed = results.filter((result) => result.passed).length;
|
|
29
|
+
const failed = results.length - passed;
|
|
30
|
+
console.log('');
|
|
31
|
+
console.log(passed + ' passed, ' + failed + ' failed');
|
|
32
|
+
}
|
|
33
|
+
program
|
|
34
|
+
.name('stable-ci')
|
|
35
|
+
.description('Reliability CI for stablecoin payment integrations')
|
|
36
|
+
.version('0.1.0');
|
|
37
|
+
program
|
|
38
|
+
.command('scenarios')
|
|
39
|
+
.description('List built-in reliability scenarios')
|
|
40
|
+
.action(() => {
|
|
41
|
+
console.log('Built-in scenarios:');
|
|
42
|
+
for (const scenario of scenarios) {
|
|
43
|
+
console.log(' ' + scenario.name);
|
|
44
|
+
console.log(' ' + scenario.description);
|
|
45
|
+
console.log(' Risk: ' + scenario.risk);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
program
|
|
49
|
+
.command('test')
|
|
50
|
+
.description('Run the in-memory reliability test suite')
|
|
51
|
+
.option('--profile <profile>', 'Demo profile: safe or unsafe', 'safe')
|
|
52
|
+
.option('--json', 'Output JSON')
|
|
53
|
+
.action(async (options) => {
|
|
54
|
+
if (options.profile !== 'safe' && options.profile !== 'unsafe') {
|
|
55
|
+
console.error('Profile must be safe or unsafe.');
|
|
56
|
+
process.exitCode = 2;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const adapter = createDemoAdapter(options.profile);
|
|
60
|
+
const results = await runSuite(adapter);
|
|
61
|
+
if (options.json) {
|
|
62
|
+
console.log(JSON.stringify({
|
|
63
|
+
adapter: adapter.name,
|
|
64
|
+
results,
|
|
65
|
+
}, null, 2));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
printResults(adapter.name, results);
|
|
69
|
+
}
|
|
70
|
+
if (results.some((result) => !result.passed)) {
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
program
|
|
75
|
+
.command('test-http')
|
|
76
|
+
.description('Run reliability scenarios using real local HTTP fault injection')
|
|
77
|
+
.option('--profile <profile>', 'HTTP demo profile: safe or unsafe', 'safe')
|
|
78
|
+
.option('--json', 'Output JSON')
|
|
79
|
+
.action(async (options) => {
|
|
80
|
+
if (options.profile !== 'safe' && options.profile !== 'unsafe') {
|
|
81
|
+
console.error('Profile must be safe or unsafe.');
|
|
82
|
+
process.exitCode = 2;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const adapter = createHttpDemoAdapter(options.profile);
|
|
86
|
+
const results = await runSuite(adapter);
|
|
87
|
+
if (options.json) {
|
|
88
|
+
console.log(JSON.stringify({
|
|
89
|
+
adapter: adapter.name,
|
|
90
|
+
results,
|
|
91
|
+
}, null, 2));
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
printResults(adapter.name, results);
|
|
95
|
+
}
|
|
96
|
+
if (results.some((result) => !result.passed)) {
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
program
|
|
101
|
+
.command('demo-server')
|
|
102
|
+
.description('Run the local demo payment application')
|
|
103
|
+
.option('--profile <profile>', 'Demo profile: safe or unsafe', 'safe')
|
|
104
|
+
.option('--port <port>', 'Port', '4310')
|
|
105
|
+
.action(async (options) => {
|
|
106
|
+
if (options.profile !== 'safe' && options.profile !== 'unsafe') {
|
|
107
|
+
console.error('Profile must be safe or unsafe.');
|
|
108
|
+
process.exitCode = 2;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const port = Number(options.port);
|
|
112
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
113
|
+
console.error('Port must be a positive integer.');
|
|
114
|
+
process.exitCode = 2;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const app = await startDemoPaymentApp(options.profile, port);
|
|
118
|
+
console.log('Demo payment app running at ' + app.baseUrl);
|
|
119
|
+
console.log('Profile: ' + options.profile);
|
|
120
|
+
console.log('Press Ctrl+C to stop.');
|
|
121
|
+
await new Promise((resolve) => {
|
|
122
|
+
let closing = false;
|
|
123
|
+
const shutdown = async () => {
|
|
124
|
+
if (closing) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
closing = true;
|
|
128
|
+
await app.close();
|
|
129
|
+
resolve();
|
|
130
|
+
};
|
|
131
|
+
process.once('SIGINT', () => {
|
|
132
|
+
void shutdown();
|
|
133
|
+
});
|
|
134
|
+
process.once('SIGTERM', () => {
|
|
135
|
+
void shutdown();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
program
|
|
140
|
+
.command('run')
|
|
141
|
+
.description('Run stable-ci against a configured payment application')
|
|
142
|
+
.option('-c, --config <path>', 'Path to stable-ci config', 'stable-ci.yml')
|
|
143
|
+
.option('--json', 'Output JSON')
|
|
144
|
+
.option('--junit <path>', 'Write JUnit XML report')
|
|
145
|
+
.action(async (options) => {
|
|
146
|
+
try {
|
|
147
|
+
const config = loadConfig(options.config);
|
|
148
|
+
const adapter = createConfiguredHttpAdapter(config);
|
|
149
|
+
const selectedScenarios = scenarios
|
|
150
|
+
.filter((scenario) => config.scenarios.includes(scenario.name))
|
|
151
|
+
.map((scenario) => ({
|
|
152
|
+
...scenario,
|
|
153
|
+
expected: {
|
|
154
|
+
...scenario.expected,
|
|
155
|
+
...(config.expectations?.[scenario.name] ?? {}),
|
|
156
|
+
},
|
|
157
|
+
}));
|
|
158
|
+
const results = await runSuite(adapter, selectedScenarios);
|
|
159
|
+
if (options.json) {
|
|
160
|
+
console.log(JSON.stringify({
|
|
161
|
+
adapter: adapter.name,
|
|
162
|
+
results,
|
|
163
|
+
}, null, 2));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
printResults(adapter.name, results);
|
|
167
|
+
}
|
|
168
|
+
if (options.junit) {
|
|
169
|
+
writeJUnit(options.junit, adapter.name, results);
|
|
170
|
+
console.log('JUnit report: ' + options.junit);
|
|
171
|
+
}
|
|
172
|
+
if (results.some((result) => !result.passed)) {
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
console.error(error instanceof Error
|
|
178
|
+
? error.message
|
|
179
|
+
: 'Unknown stable-ci error.');
|
|
180
|
+
process.exitCode = 2;
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
program.parseAsync();
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const moneyEqual = (a, b) => Math.abs(a - b) < 0.005;
|
|
2
|
+
function expectedStatuses(expected) {
|
|
3
|
+
if (expected.applicationStatus === undefined) {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
return Array.isArray(expected.applicationStatus)
|
|
7
|
+
? expected.applicationStatus
|
|
8
|
+
: [expected.applicationStatus];
|
|
9
|
+
}
|
|
10
|
+
export function evaluateInvariants(o, expected) {
|
|
11
|
+
const results = [];
|
|
12
|
+
if (expected.ledgerEntries !== undefined) {
|
|
13
|
+
const passed = o.ledgerEntries === expected.ledgerEntries;
|
|
14
|
+
results.push({
|
|
15
|
+
name: expected.ledgerEntries === 1
|
|
16
|
+
? 'payment_posts_exactly_once'
|
|
17
|
+
: 'expected_ledger_entries',
|
|
18
|
+
passed,
|
|
19
|
+
message: passed
|
|
20
|
+
? undefined
|
|
21
|
+
: 'Expected ' +
|
|
22
|
+
expected.ledgerEntries +
|
|
23
|
+
' ledger entry/entries, observed ' +
|
|
24
|
+
o.ledgerEntries +
|
|
25
|
+
'.'
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (expected.credit === 'exact_expected') {
|
|
29
|
+
const passed = moneyEqual(o.creditedAmount, o.expectedAmount);
|
|
30
|
+
results.push({
|
|
31
|
+
name: 'ledger_delta_equals_settlement_amount',
|
|
32
|
+
passed,
|
|
33
|
+
message: passed
|
|
34
|
+
? undefined
|
|
35
|
+
: 'Expected USD ' +
|
|
36
|
+
o.expectedAmount.toFixed(2) +
|
|
37
|
+
', ledger credited USD ' +
|
|
38
|
+
o.creditedAmount.toFixed(2) +
|
|
39
|
+
'.'
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (expected.credit === 'none') {
|
|
43
|
+
const passed = moneyEqual(o.creditedAmount, 0);
|
|
44
|
+
results.push({
|
|
45
|
+
name: 'failed_payment_never_credits_balance',
|
|
46
|
+
passed,
|
|
47
|
+
message: passed
|
|
48
|
+
? undefined
|
|
49
|
+
: 'Expected no credit, but USD ' +
|
|
50
|
+
o.creditedAmount.toFixed(2) +
|
|
51
|
+
' was credited.'
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (expected.credit === 'received_amount') {
|
|
55
|
+
const hasReceivedAmount = o.receivedAmount !== undefined;
|
|
56
|
+
const passed = hasReceivedAmount &&
|
|
57
|
+
moneyEqual(o.creditedAmount, o.receivedAmount);
|
|
58
|
+
results.push({
|
|
59
|
+
name: 'ledger_delta_equals_received_amount',
|
|
60
|
+
passed,
|
|
61
|
+
message: passed
|
|
62
|
+
? undefined
|
|
63
|
+
: hasReceivedAmount
|
|
64
|
+
? 'Expected received USD ' +
|
|
65
|
+
o.receivedAmount.toFixed(2) +
|
|
66
|
+
', ledger credited USD ' +
|
|
67
|
+
o.creditedAmount.toFixed(2) +
|
|
68
|
+
'.'
|
|
69
|
+
: 'Scenario did not report receivedAmount.'
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const allowedStatuses = expectedStatuses(expected);
|
|
73
|
+
if (allowedStatuses !== undefined) {
|
|
74
|
+
const passed = allowedStatuses.includes(o.applicationStatus);
|
|
75
|
+
const completedOnly = allowedStatuses.length === 1 &&
|
|
76
|
+
allowedStatuses[0] === 'completed';
|
|
77
|
+
results.push({
|
|
78
|
+
name: completedOnly
|
|
79
|
+
? 'confirmed_payment_must_end_completed'
|
|
80
|
+
: 'expected_application_status',
|
|
81
|
+
passed,
|
|
82
|
+
message: passed
|
|
83
|
+
? undefined
|
|
84
|
+
: 'Expected application state ' +
|
|
85
|
+
allowedStatuses.join(' or ') +
|
|
86
|
+
', observed ' +
|
|
87
|
+
o.applicationStatus +
|
|
88
|
+
'.'
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (expected.retryAttempts !== undefined) {
|
|
92
|
+
const passed = o.retryAttempts === expected.retryAttempts;
|
|
93
|
+
results.push({
|
|
94
|
+
name: expected.retryAttempts === 0
|
|
95
|
+
? 'unknown_settlement_must_not_be_retried'
|
|
96
|
+
: 'expected_retry_attempts',
|
|
97
|
+
passed,
|
|
98
|
+
message: passed
|
|
99
|
+
? undefined
|
|
100
|
+
: 'Expected ' +
|
|
101
|
+
expected.retryAttempts +
|
|
102
|
+
' retry attempt(s), observed ' +
|
|
103
|
+
o.retryAttempts +
|
|
104
|
+
'.'
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (expected.webhookAccepted !== undefined) {
|
|
108
|
+
const passed = o.webhookAccepted === expected.webhookAccepted;
|
|
109
|
+
results.push({
|
|
110
|
+
name: expected.webhookAccepted === false
|
|
111
|
+
? 'invalid_signature_must_be_rejected'
|
|
112
|
+
: 'expected_webhook_acceptance',
|
|
113
|
+
passed,
|
|
114
|
+
message: passed
|
|
115
|
+
? undefined
|
|
116
|
+
: 'Expected webhookAccepted=' +
|
|
117
|
+
expected.webhookAccepted +
|
|
118
|
+
', observed ' +
|
|
119
|
+
String(o.webhookAccepted) +
|
|
120
|
+
'.'
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (o.scenario === 'duplicate_webhook') {
|
|
124
|
+
const passed = o.webhookDeliveries <= 1 ||
|
|
125
|
+
o.ledgerEntries <= 1;
|
|
126
|
+
results.push({
|
|
127
|
+
name: 'duplicate_webhook_must_not_duplicate_ledger',
|
|
128
|
+
passed,
|
|
129
|
+
message: passed
|
|
130
|
+
? undefined
|
|
131
|
+
: o.webhookDeliveries +
|
|
132
|
+
' webhook deliveries created ' +
|
|
133
|
+
o.ledgerEntries +
|
|
134
|
+
' ledger entries.'
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return results;
|
|
138
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
function toBvnkStatus(status) {
|
|
3
|
+
switch (status) {
|
|
4
|
+
case 'pending':
|
|
5
|
+
return 'PROCESSING';
|
|
6
|
+
case 'completed':
|
|
7
|
+
return 'COMPLETE';
|
|
8
|
+
case 'failed':
|
|
9
|
+
case 'expired':
|
|
10
|
+
return 'EXPIRED';
|
|
11
|
+
case 'underpaid':
|
|
12
|
+
return 'UNDERPAID';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function createBvnkProvider(webhookSecret = 'stable-ci-local-secret') {
|
|
16
|
+
return {
|
|
17
|
+
name: 'bvnk',
|
|
18
|
+
render(event) {
|
|
19
|
+
const eventType = event.eventType ?? 'statusChanged';
|
|
20
|
+
const actualAmount = event.actualAmount ?? event.amount;
|
|
21
|
+
const payload = {
|
|
22
|
+
source: 'payment',
|
|
23
|
+
event: eventType,
|
|
24
|
+
data: {
|
|
25
|
+
uuid: event.paymentId,
|
|
26
|
+
reference: event.eventId,
|
|
27
|
+
type: 'IN',
|
|
28
|
+
subType: 'merchantPayIn',
|
|
29
|
+
status: toBvnkStatus(event.status),
|
|
30
|
+
displayCurrency: {
|
|
31
|
+
currency: event.asset,
|
|
32
|
+
amount: event.amount,
|
|
33
|
+
actual: actualAmount,
|
|
34
|
+
},
|
|
35
|
+
paidCurrency: {
|
|
36
|
+
currency: event.asset,
|
|
37
|
+
amount: event.amount,
|
|
38
|
+
actual: actualAmount,
|
|
39
|
+
},
|
|
40
|
+
walletCurrency: {
|
|
41
|
+
currency: event.asset,
|
|
42
|
+
amount: event.amount,
|
|
43
|
+
actual: actualAmount,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const body = JSON.stringify(payload);
|
|
48
|
+
const signature = createHmac('sha256', webhookSecret)
|
|
49
|
+
.update(body, 'utf8')
|
|
50
|
+
.digest('base64');
|
|
51
|
+
return {
|
|
52
|
+
body,
|
|
53
|
+
headers: {
|
|
54
|
+
'content-type': 'application/json',
|
|
55
|
+
'x-signature': signature,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function escapeXml(value) {
|
|
4
|
+
return value
|
|
5
|
+
.replaceAll('&', '&')
|
|
6
|
+
.replaceAll('<', '<')
|
|
7
|
+
.replaceAll('>', '>')
|
|
8
|
+
.replaceAll('"', '"')
|
|
9
|
+
.replaceAll("'", ''');
|
|
10
|
+
}
|
|
11
|
+
function failureText(result) {
|
|
12
|
+
return result.invariants
|
|
13
|
+
.filter((invariant) => !invariant.passed)
|
|
14
|
+
.map((invariant) => {
|
|
15
|
+
if (invariant.message) {
|
|
16
|
+
return invariant.name + ': ' + invariant.message;
|
|
17
|
+
}
|
|
18
|
+
return invariant.name;
|
|
19
|
+
})
|
|
20
|
+
.join('\n');
|
|
21
|
+
}
|
|
22
|
+
export function renderJUnit(adapterName, results) {
|
|
23
|
+
const failures = results.filter((result) => !result.passed).length;
|
|
24
|
+
const testcases = results.map((result) => {
|
|
25
|
+
const name = escapeXml(result.scenario.name);
|
|
26
|
+
const className = escapeXml('stable-ci.' + adapterName);
|
|
27
|
+
if (result.passed) {
|
|
28
|
+
return [
|
|
29
|
+
' <testcase',
|
|
30
|
+
' classname="' + className + '"',
|
|
31
|
+
' name="' + name + '"',
|
|
32
|
+
' />',
|
|
33
|
+
].join('\n');
|
|
34
|
+
}
|
|
35
|
+
const message = failureText(result);
|
|
36
|
+
return [
|
|
37
|
+
' <testcase',
|
|
38
|
+
' classname="' + className + '"',
|
|
39
|
+
' name="' + name + '"',
|
|
40
|
+
' >',
|
|
41
|
+
' <failure message="Stablecoin reliability invariant failed">',
|
|
42
|
+
escapeXml(message),
|
|
43
|
+
' </failure>',
|
|
44
|
+
' </testcase>',
|
|
45
|
+
].join('\n');
|
|
46
|
+
});
|
|
47
|
+
return [
|
|
48
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
49
|
+
'<testsuite',
|
|
50
|
+
' name="stable-ci"',
|
|
51
|
+
' tests="' + results.length + '"',
|
|
52
|
+
' failures="' + failures + '"',
|
|
53
|
+
'>',
|
|
54
|
+
...testcases,
|
|
55
|
+
'</testsuite>',
|
|
56
|
+
'',
|
|
57
|
+
].join('\n');
|
|
58
|
+
}
|
|
59
|
+
export function writeJUnit(filePath, adapterName, results) {
|
|
60
|
+
const directory = path.dirname(path.resolve(filePath));
|
|
61
|
+
fs.mkdirSync(directory, {
|
|
62
|
+
recursive: true,
|
|
63
|
+
});
|
|
64
|
+
fs.writeFileSync(filePath, renderJUnit(adapterName, results), 'utf8');
|
|
65
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const scenarios = [
|
|
2
|
+
{
|
|
3
|
+
name: 'duplicate_webhook',
|
|
4
|
+
description: 'The same payment webhook is delivered more than once.',
|
|
5
|
+
risk: 'Duplicate ledger posting or duplicate credit',
|
|
6
|
+
expected: {
|
|
7
|
+
applicationStatus: 'completed',
|
|
8
|
+
ledgerEntries: 1,
|
|
9
|
+
credit: 'exact_expected',
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'out_of_order_webhook',
|
|
14
|
+
description: 'Payment lifecycle events arrive out of order.',
|
|
15
|
+
risk: 'State regression or incorrect final payment state',
|
|
16
|
+
expected: {
|
|
17
|
+
applicationStatus: 'completed',
|
|
18
|
+
ledgerEntries: 1,
|
|
19
|
+
credit: 'exact_expected',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'missing_webhook',
|
|
24
|
+
description: 'The provider completes the payment but the webhook is never delivered.',
|
|
25
|
+
risk: 'Confirmed payment never reaches the internal ledger',
|
|
26
|
+
expected: {
|
|
27
|
+
applicationStatus: 'completed',
|
|
28
|
+
ledgerEntries: 1,
|
|
29
|
+
credit: 'exact_expected',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'provider_timeout_after_broadcast',
|
|
34
|
+
description: 'The provider times out after broadcasting the transaction.',
|
|
35
|
+
risk: 'Unsafe retry can create a duplicate payment',
|
|
36
|
+
expected: {
|
|
37
|
+
applicationStatus: 'completed',
|
|
38
|
+
ledgerEntries: 1,
|
|
39
|
+
credit: 'exact_expected',
|
|
40
|
+
retryAttempts: 0,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'late_chain_confirmation',
|
|
45
|
+
description: 'The chain confirms after the application has already observed a timeout.',
|
|
46
|
+
risk: 'Application and blockchain disagree about settlement',
|
|
47
|
+
expected: {
|
|
48
|
+
applicationStatus: 'completed',
|
|
49
|
+
ledgerEntries: 1,
|
|
50
|
+
credit: 'exact_expected',
|
|
51
|
+
retryAttempts: 0,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'retry_after_unknown_settlement',
|
|
56
|
+
description: 'A retry is requested while the original settlement outcome is still unknown.',
|
|
57
|
+
risk: 'Double payment or duplicate payout',
|
|
58
|
+
expected: {
|
|
59
|
+
applicationStatus: 'completed',
|
|
60
|
+
ledgerEntries: 1,
|
|
61
|
+
credit: 'exact_expected',
|
|
62
|
+
retryAttempts: 0,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'invalid_signature',
|
|
67
|
+
description: 'A webhook is delivered with an invalid provider signature.',
|
|
68
|
+
risk: 'Forged payment events may be accepted and credited',
|
|
69
|
+
expected: {
|
|
70
|
+
applicationStatus: 'none',
|
|
71
|
+
ledgerEntries: 0,
|
|
72
|
+
credit: 'none',
|
|
73
|
+
webhookAccepted: false,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'underpayment',
|
|
78
|
+
description: 'The customer sends less than the requested payment amount.',
|
|
79
|
+
risk: 'Partial payment may be incorrectly treated as fully paid',
|
|
80
|
+
expected: {
|
|
81
|
+
applicationStatus: 'manual_review',
|
|
82
|
+
ledgerEntries: 0,
|
|
83
|
+
credit: 'none',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'overpayment',
|
|
88
|
+
description: 'The customer sends more than the requested payment amount.',
|
|
89
|
+
risk: 'Excess funds may be incorrectly credited to the main customer balance',
|
|
90
|
+
expected: {
|
|
91
|
+
applicationStatus: 'completed',
|
|
92
|
+
ledgerEntries: 1,
|
|
93
|
+
credit: 'exact_expected',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'late_payment',
|
|
98
|
+
description: 'Funds arrive after the original payment has already expired.',
|
|
99
|
+
risk: 'Expired payment may be incorrectly revived and credited as a normal payment',
|
|
100
|
+
expected: {
|
|
101
|
+
applicationStatus: 'manual_review',
|
|
102
|
+
ledgerEntries: 0,
|
|
103
|
+
credit: 'none',
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
];
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stable-ci",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reliability CI for stablecoin payment integrations",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "vitest run",
|
|
8
|
+
"dev": "tsx src/index.ts",
|
|
9
|
+
"test:watch": "vitest",
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"check": "npm test && npm run build",
|
|
12
|
+
"prepublishOnly": "npm run check"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"stablecoin",
|
|
16
|
+
"payments",
|
|
17
|
+
"webhook",
|
|
18
|
+
"reliability",
|
|
19
|
+
"testing",
|
|
20
|
+
"ci",
|
|
21
|
+
"bvnk"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"stable-ci": "dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"commander": "^15.0.0",
|
|
31
|
+
"yaml": "^2.9.1",
|
|
32
|
+
"zod": "^4.6.5"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^26.6.2",
|
|
36
|
+
"tsx": "^4.23.13",
|
|
37
|
+
"typescript": "^7.0.2",
|
|
38
|
+
"vitest": "^5.0.1"
|
|
39
|
+
},
|
|
40
|
+
"allowScripts": {
|
|
41
|
+
"esbuild@0.28.2": true
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist"
|
|
48
|
+
],
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/saldfsdk/stable-ci.git"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/saldfsdk/stable-ci#readme",
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/saldfsdk/stable-ci/issues"
|
|
56
|
+
}
|
|
57
|
+
}
|