autotel-pact 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 +385 -0
- package/dist/audit.cjs +412 -0
- package/dist/audit.cjs.map +1 -0
- package/dist/audit.d.cts +25 -0
- package/dist/audit.d.ts +25 -0
- package/dist/audit.js +403 -0
- package/dist/audit.js.map +1 -0
- package/dist/auto-wrap.cjs +255 -0
- package/dist/auto-wrap.cjs.map +1 -0
- package/dist/auto-wrap.d.cts +57 -0
- package/dist/auto-wrap.d.ts +57 -0
- package/dist/auto-wrap.js +248 -0
- package/dist/auto-wrap.js.map +1 -0
- package/dist/broker.cjs +84 -0
- package/dist/broker.cjs.map +1 -0
- package/dist/broker.d.cts +23 -0
- package/dist/broker.d.ts +23 -0
- package/dist/broker.js +80 -0
- package/dist/broker.js.map +1 -0
- package/dist/cli.cjs +662 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +4 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +656 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +967 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +200 -0
- package/dist/index.d.ts +200 -0
- package/dist/index.js +932 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger-BuBmfWNc.d.ts +22 -0
- package/dist/ledger-D88TzN1c.d.cts +22 -0
- package/dist/processor.cjs +200 -0
- package/dist/processor.cjs.map +1 -0
- package/dist/processor.d.cts +58 -0
- package/dist/processor.d.ts +58 -0
- package/dist/processor.js +193 -0
- package/dist/processor.js.map +1 -0
- package/dist/provider.cjs +219 -0
- package/dist/provider.cjs.map +1 -0
- package/dist/provider.d.cts +41 -0
- package/dist/provider.d.ts +41 -0
- package/dist/provider.js +213 -0
- package/dist/provider.js.map +1 -0
- package/dist/tag.cjs +50 -0
- package/dist/tag.cjs.map +1 -0
- package/dist/tag.d.cts +9 -0
- package/dist/tag.d.ts +9 -0
- package/dist/tag.js +48 -0
- package/dist/tag.js.map +1 -0
- package/dist/types-BHGiwqcp.d.cts +157 -0
- package/dist/types-BHGiwqcp.d.ts +157 -0
- package/package.json +108 -0
- package/schemas/README.md +24 -0
- package/schemas/audit-matrix-v0.2.0.json +78 -0
- package/schemas/ledger-entry-v0.2.0.json +77 -0
- package/src/attrs.test.ts +35 -0
- package/src/attrs.ts +53 -0
- package/src/audit.test.ts +189 -0
- package/src/audit.ts +251 -0
- package/src/auto-wrap.test.ts +149 -0
- package/src/auto-wrap.ts +283 -0
- package/src/broker.test.ts +175 -0
- package/src/broker.ts +118 -0
- package/src/cli.test.ts +148 -0
- package/src/cli.ts +287 -0
- package/src/index.ts +94 -0
- package/src/labels.ts +25 -0
- package/src/ledger-normalize.test.ts +141 -0
- package/src/ledger-normalize.ts +82 -0
- package/src/ledger.test.ts +92 -0
- package/src/ledger.ts +156 -0
- package/src/pact-file.test.ts +124 -0
- package/src/pact-file.ts +65 -0
- package/src/processor.test.ts +90 -0
- package/src/processor.ts +191 -0
- package/src/tag.test.ts +72 -0
- package/src/tag.ts +21 -0
- package/src/types.ts +169 -0
- package/src/wrapper-http.test.ts +133 -0
- package/src/wrapper-http.ts +194 -0
- package/src/wrapper-provider.test.ts +132 -0
- package/src/wrapper-provider.ts +163 -0
- package/src/wrapper.test.ts +176 -0
- package/src/wrapper.ts +221 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { computeAuditMatrix, runAuditSync } from './audit.js';
|
|
6
|
+
import type { InteractionLedgerEntry, PactFile } from './types.js';
|
|
7
|
+
import { AUDIT_MATRIX_SPEC, LEDGER_ENTRY_SPEC } from './types.js';
|
|
8
|
+
|
|
9
|
+
const NOW = new Date('2026-06-01T00:00:00.000Z');
|
|
10
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
function entry(overrides: Partial<InteractionLedgerEntry>): InteractionLedgerEntry {
|
|
13
|
+
return {
|
|
14
|
+
type: 'interaction',
|
|
15
|
+
spec: LEDGER_ENTRY_SPEC,
|
|
16
|
+
consumer: 'A',
|
|
17
|
+
provider: 'B',
|
|
18
|
+
interaction: 'evt',
|
|
19
|
+
states: [],
|
|
20
|
+
kind: 'message',
|
|
21
|
+
source: 'test',
|
|
22
|
+
role: 'consumer',
|
|
23
|
+
outcome: 'passed',
|
|
24
|
+
duration_ms: 1,
|
|
25
|
+
observed_at: NOW.toISOString(),
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('computeAuditMatrix', () => {
|
|
31
|
+
it('marks contracted-and-seen-in-test as OK', () => {
|
|
32
|
+
const m = computeAuditMatrix({
|
|
33
|
+
contracted: [{ consumer: 'A', provider: 'B', interaction: 'evt', kind: 'message' }],
|
|
34
|
+
ledger: [entry({})],
|
|
35
|
+
now: NOW,
|
|
36
|
+
});
|
|
37
|
+
expect(m.counts).toMatchObject({ contracted_and_test_seen: 1, contracted_not_test_seen: 0 });
|
|
38
|
+
expect(m.rows[0]).toMatchObject({ contracted: true, test_seen: true, observed: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('flags contracted-but-not-seen-in-test (STALE)', () => {
|
|
42
|
+
const m = computeAuditMatrix({
|
|
43
|
+
contracted: [{ consumer: 'A', provider: 'B', interaction: 'evt', kind: 'message' }],
|
|
44
|
+
ledger: [],
|
|
45
|
+
now: NOW,
|
|
46
|
+
});
|
|
47
|
+
expect(m.counts.contracted_not_test_seen).toBe(1);
|
|
48
|
+
expect(m.rows[0]).toMatchObject({ contracted: true, test_seen: false });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('flags seen-but-not-contracted', () => {
|
|
52
|
+
const m = computeAuditMatrix({
|
|
53
|
+
contracted: [],
|
|
54
|
+
ledger: [entry({})],
|
|
55
|
+
now: NOW,
|
|
56
|
+
});
|
|
57
|
+
expect(m.counts.test_or_prod_seen_not_contracted).toBe(1);
|
|
58
|
+
expect(m.rows[0]).toMatchObject({ contracted: false, test_seen: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('tracks prod_seen separately from test_seen', () => {
|
|
62
|
+
const m = computeAuditMatrix({
|
|
63
|
+
contracted: [{ consumer: 'A', provider: 'B', interaction: 'evt', kind: 'message' }],
|
|
64
|
+
ledger: [entry({ source: 'production' })],
|
|
65
|
+
now: NOW,
|
|
66
|
+
});
|
|
67
|
+
expect(m.rows[0]).toMatchObject({
|
|
68
|
+
test_seen: false,
|
|
69
|
+
prod_seen: true,
|
|
70
|
+
});
|
|
71
|
+
expect(m.counts.contracted_not_test_seen).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('sets provider_verified only for provider role passed entries', () => {
|
|
75
|
+
const m = computeAuditMatrix({
|
|
76
|
+
contracted: [{ consumer: 'A', provider: 'B', interaction: 'evt', kind: 'message' }],
|
|
77
|
+
ledger: [
|
|
78
|
+
entry({ role: 'provider', outcome: 'passed' }),
|
|
79
|
+
],
|
|
80
|
+
now: NOW,
|
|
81
|
+
});
|
|
82
|
+
expect(m.rows[0]!.provider_verified).toBe(true);
|
|
83
|
+
expect(m.rows[0]!.test_seen).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('ignores provider_verification_run for per-interaction columns', () => {
|
|
87
|
+
const m = computeAuditMatrix({
|
|
88
|
+
contracted: [{ consumer: 'A', provider: 'B', interaction: 'evt', kind: 'message' }],
|
|
89
|
+
ledger: [
|
|
90
|
+
{
|
|
91
|
+
type: 'provider_verification_run',
|
|
92
|
+
spec: LEDGER_ENTRY_SPEC,
|
|
93
|
+
consumer: 'A',
|
|
94
|
+
provider: 'B',
|
|
95
|
+
outcome: 'failed',
|
|
96
|
+
source: 'test',
|
|
97
|
+
role: 'provider',
|
|
98
|
+
observed_at: NOW.toISOString(),
|
|
99
|
+
error: 'setup failed',
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
now: NOW,
|
|
103
|
+
});
|
|
104
|
+
expect(m.rows[0]!.provider_verified).toBe(false);
|
|
105
|
+
expect(m.verification_failures).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('applies broker_verified at pact-pair level', () => {
|
|
109
|
+
const m = computeAuditMatrix({
|
|
110
|
+
contracted: [
|
|
111
|
+
{ consumer: 'A', provider: 'B', interaction: 'evt1', kind: 'message' },
|
|
112
|
+
{ consumer: 'A', provider: 'B', interaction: 'evt2', kind: 'message' },
|
|
113
|
+
],
|
|
114
|
+
ledger: [],
|
|
115
|
+
brokerVerifications: [
|
|
116
|
+
{ consumer: 'A', provider: 'B', success: true, verifiedAt: NOW.toISOString() },
|
|
117
|
+
],
|
|
118
|
+
now: NOW,
|
|
119
|
+
});
|
|
120
|
+
expect(m.rows.every((r) => r.broker_verified)).toBe(true);
|
|
121
|
+
expect(m.counts.broker_verified).toBe(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('excludes ledger entries outside the window', () => {
|
|
125
|
+
const oldEntry = entry({
|
|
126
|
+
observed_at: new Date(NOW.getTime() - 20 * DAY).toISOString(),
|
|
127
|
+
});
|
|
128
|
+
const m = computeAuditMatrix({
|
|
129
|
+
contracted: [{ consumer: 'A', provider: 'B', interaction: 'evt', kind: 'message' }],
|
|
130
|
+
ledger: [oldEntry],
|
|
131
|
+
windowDays: 14,
|
|
132
|
+
now: NOW,
|
|
133
|
+
});
|
|
134
|
+
expect(m.counts.contracted_not_test_seen).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('emits v0.2 audit matrix spec', () => {
|
|
138
|
+
const m = computeAuditMatrix({
|
|
139
|
+
contracted: [],
|
|
140
|
+
ledger: [],
|
|
141
|
+
now: NOW,
|
|
142
|
+
});
|
|
143
|
+
expect(m.spec).toBe(AUDIT_MATRIX_SPEC);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('runAuditSync (integration)', () => {
|
|
148
|
+
let workDir: string;
|
|
149
|
+
let originalCwd: string;
|
|
150
|
+
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
originalCwd = process.cwd();
|
|
153
|
+
workDir = mkdtempSync(path.join(tmpdir(), 'autotel-pact-audit-'));
|
|
154
|
+
process.chdir(workDir);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
afterEach(() => {
|
|
158
|
+
process.chdir(originalCwd);
|
|
159
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('reads pacts and ledger from disk and produces a matrix', () => {
|
|
163
|
+
mkdirSync('pacts', { recursive: true });
|
|
164
|
+
const pact: PactFile = {
|
|
165
|
+
consumer: { name: 'OrderShipper' },
|
|
166
|
+
provider: { name: 'OrderService' },
|
|
167
|
+
messages: [
|
|
168
|
+
{ description: 'an OrderCreated event' },
|
|
169
|
+
{ description: 'an OrderCancelled event' },
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
writeFileSync('pacts/OrderShipper-OrderService.json', JSON.stringify(pact));
|
|
173
|
+
|
|
174
|
+
mkdirSync('.autotel-pact', { recursive: true });
|
|
175
|
+
const observed = entry({
|
|
176
|
+
consumer: 'OrderShipper',
|
|
177
|
+
provider: 'OrderService',
|
|
178
|
+
interaction: 'an OrderCreated event',
|
|
179
|
+
observed_at: new Date().toISOString(),
|
|
180
|
+
});
|
|
181
|
+
writeFileSync('.autotel-pact/ledger-x.jsonl', JSON.stringify(observed) + '\n');
|
|
182
|
+
|
|
183
|
+
const m = runAuditSync({});
|
|
184
|
+
|
|
185
|
+
expect(m.counts.contracted).toBe(2);
|
|
186
|
+
expect(m.counts.contracted_and_test_seen).toBe(1);
|
|
187
|
+
expect(m.counts.contracted_not_test_seen).toBe(1);
|
|
188
|
+
});
|
|
189
|
+
});
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fetchBrokerVerifications, type BrokerConfig } from './broker.js';
|
|
3
|
+
import { readLedger, type LedgerOptions } from './ledger.js';
|
|
4
|
+
import {
|
|
5
|
+
interactionsFromPactFile,
|
|
6
|
+
listPactFiles,
|
|
7
|
+
parsePactFile,
|
|
8
|
+
} from './pact-file.js';
|
|
9
|
+
import {
|
|
10
|
+
AUDIT_MATRIX_SPEC,
|
|
11
|
+
isInteractionLedgerEntry,
|
|
12
|
+
isProviderVerificationRun,
|
|
13
|
+
type AuditMatrix,
|
|
14
|
+
type AuditRow,
|
|
15
|
+
type BrokerVerification,
|
|
16
|
+
type InteractionLedgerEntry,
|
|
17
|
+
type LedgerRecord,
|
|
18
|
+
type PactInteractionKey,
|
|
19
|
+
type ProviderVerificationRunEntry,
|
|
20
|
+
} from './types.js';
|
|
21
|
+
|
|
22
|
+
export interface AuditOptions extends LedgerOptions {
|
|
23
|
+
pactsDir?: string;
|
|
24
|
+
windowDays?: number;
|
|
25
|
+
broker?: BrokerConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_PACTS_DIR = './pacts';
|
|
29
|
+
const DEFAULT_WINDOW_DAYS = 14;
|
|
30
|
+
|
|
31
|
+
export function keyOf(k: PactInteractionKey): string {
|
|
32
|
+
const identity = k.interactionId ?? k.interaction;
|
|
33
|
+
return `${k.consumer}::${k.provider}::${k.kind}::${identity}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pairKey(consumer: string, provider: string): string {
|
|
37
|
+
return `${consumer}::${provider}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function inWindow(observedAt: string, cutoff: number): boolean {
|
|
41
|
+
const t = Date.parse(observedAt);
|
|
42
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compute the audit matrix from pact files, ledger, and optional broker data.
|
|
47
|
+
*/
|
|
48
|
+
export function computeAuditMatrix(input: {
|
|
49
|
+
contracted: PactInteractionKey[];
|
|
50
|
+
ledger: LedgerRecord[];
|
|
51
|
+
brokerVerifications?: BrokerVerification[];
|
|
52
|
+
windowDays?: number;
|
|
53
|
+
now?: Date;
|
|
54
|
+
}): AuditMatrix {
|
|
55
|
+
const windowDays = input.windowDays ?? DEFAULT_WINDOW_DAYS;
|
|
56
|
+
const now = input.now ?? new Date();
|
|
57
|
+
const cutoff = now.getTime() - windowDays * 24 * 60 * 60 * 1000;
|
|
58
|
+
|
|
59
|
+
const verificationFailures: ProviderVerificationRunEntry[] = [];
|
|
60
|
+
const recentInteractions: InteractionLedgerEntry[] = [];
|
|
61
|
+
|
|
62
|
+
for (const entry of input.ledger) {
|
|
63
|
+
if (!inWindow(entry.observed_at, cutoff)) continue;
|
|
64
|
+
if (isProviderVerificationRun(entry)) {
|
|
65
|
+
verificationFailures.push(entry);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (isInteractionLedgerEntry(entry)) {
|
|
69
|
+
recentInteractions.push(entry);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const brokerByPair = new Map<string, BrokerVerification>();
|
|
74
|
+
for (const b of input.brokerVerifications ?? []) {
|
|
75
|
+
brokerByPair.set(pairKey(b.consumer, b.provider), b);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const testSeenByKey = new Map<string, InteractionLedgerEntry[]>();
|
|
79
|
+
const prodSeenByKey = new Map<string, InteractionLedgerEntry[]>();
|
|
80
|
+
const providerVerifiedByKey = new Map<string, InteractionLedgerEntry[]>();
|
|
81
|
+
const anyObservedByKey = new Map<string, InteractionLedgerEntry[]>();
|
|
82
|
+
|
|
83
|
+
for (const entry of recentInteractions) {
|
|
84
|
+
const k = keyOf({
|
|
85
|
+
consumer: entry.consumer,
|
|
86
|
+
provider: entry.provider,
|
|
87
|
+
interaction: entry.interaction,
|
|
88
|
+
kind: entry.kind,
|
|
89
|
+
interactionId: entry.interaction_id,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const push = (map: Map<string, InteractionLedgerEntry[]>) => {
|
|
93
|
+
const arr = map.get(k) ?? [];
|
|
94
|
+
arr.push(entry);
|
|
95
|
+
map.set(k, arr);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
push(anyObservedByKey);
|
|
99
|
+
|
|
100
|
+
if (entry.source === 'test' && entry.role === 'consumer') {
|
|
101
|
+
push(testSeenByKey);
|
|
102
|
+
}
|
|
103
|
+
if (entry.source === 'production') {
|
|
104
|
+
push(prodSeenByKey);
|
|
105
|
+
}
|
|
106
|
+
if (entry.role === 'provider' && entry.outcome === 'passed') {
|
|
107
|
+
push(providerVerifiedByKey);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const contractedByKey = new Map<string, PactInteractionKey>();
|
|
112
|
+
for (const c of input.contracted) {
|
|
113
|
+
contractedByKey.set(keyOf(c), c);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rows: AuditRow[] = [];
|
|
117
|
+
|
|
118
|
+
function pushRow(parts: PactInteractionKey, k: string, isContracted: boolean): void {
|
|
119
|
+
const testObs = testSeenByKey.get(k) ?? [];
|
|
120
|
+
const prodObs = prodSeenByKey.get(k) ?? [];
|
|
121
|
+
const providerObs = providerVerifiedByKey.get(k) ?? [];
|
|
122
|
+
const allObs = anyObservedByKey.get(k) ?? [];
|
|
123
|
+
const latest = allObs.toSorted((a, b) =>
|
|
124
|
+
b.observed_at.localeCompare(a.observed_at),
|
|
125
|
+
)[0];
|
|
126
|
+
const broker = brokerByPair.get(pairKey(parts.consumer, parts.provider));
|
|
127
|
+
|
|
128
|
+
rows.push({
|
|
129
|
+
consumer: parts.consumer,
|
|
130
|
+
provider: parts.provider,
|
|
131
|
+
interaction: parts.interaction,
|
|
132
|
+
interaction_id: parts.interactionId ?? latest?.interaction_id,
|
|
133
|
+
kind: parts.kind,
|
|
134
|
+
contracted: isContracted,
|
|
135
|
+
observed: testObs.length > 0 || prodObs.length > 0,
|
|
136
|
+
test_seen: testObs.length > 0,
|
|
137
|
+
prod_seen: prodObs.length > 0,
|
|
138
|
+
provider_verified: providerObs.length > 0,
|
|
139
|
+
broker_verified: broker?.success === true,
|
|
140
|
+
broker_verified_at: broker?.verifiedAt,
|
|
141
|
+
broker_error: broker?.error,
|
|
142
|
+
last_observed_at: latest?.observed_at,
|
|
143
|
+
last_outcome: latest?.outcome,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const [k, contracted] of contractedByKey) {
|
|
148
|
+
pushRow(contracted, k, true);
|
|
149
|
+
}
|
|
150
|
+
for (const [k, observations] of anyObservedByKey) {
|
|
151
|
+
if (contractedByKey.has(k)) continue;
|
|
152
|
+
const first = observations[0]!;
|
|
153
|
+
pushRow(
|
|
154
|
+
{
|
|
155
|
+
consumer: first.consumer,
|
|
156
|
+
provider: first.provider,
|
|
157
|
+
interaction: first.interaction,
|
|
158
|
+
kind: first.kind,
|
|
159
|
+
interactionId: first.interaction_id,
|
|
160
|
+
},
|
|
161
|
+
k,
|
|
162
|
+
false,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
rows.sort((a, b) =>
|
|
167
|
+
a.consumer.localeCompare(b.consumer) ||
|
|
168
|
+
a.provider.localeCompare(b.provider) ||
|
|
169
|
+
a.interaction.localeCompare(b.interaction),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const counts = {
|
|
173
|
+
total: rows.length,
|
|
174
|
+
contracted: rows.filter((r) => r.contracted).length,
|
|
175
|
+
observed: rows.filter((r) => r.observed).length,
|
|
176
|
+
contracted_and_test_seen: rows.filter((r) => r.contracted && r.test_seen).length,
|
|
177
|
+
contracted_not_test_seen: rows.filter((r) => r.contracted && !r.test_seen).length,
|
|
178
|
+
test_or_prod_seen_not_contracted: rows.filter(
|
|
179
|
+
(r) => !r.contracted && (r.test_seen || r.prod_seen),
|
|
180
|
+
).length,
|
|
181
|
+
test_seen: rows.filter((r) => r.test_seen).length,
|
|
182
|
+
prod_seen: rows.filter((r) => r.prod_seen).length,
|
|
183
|
+
provider_verified: rows.filter((r) => r.provider_verified).length,
|
|
184
|
+
broker_verified: rows.filter((r) => r.broker_verified).length,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const matrix: AuditMatrix = {
|
|
188
|
+
spec: AUDIT_MATRIX_SPEC,
|
|
189
|
+
rows,
|
|
190
|
+
counts,
|
|
191
|
+
window_days: windowDays,
|
|
192
|
+
generated_at: now.toISOString(),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (verificationFailures.length > 0) {
|
|
196
|
+
matrix.verification_failures = verificationFailures;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return matrix;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function runAudit(opts: AuditOptions = {}): Promise<AuditMatrix> {
|
|
203
|
+
const pactsDir = path.resolve(process.cwd(), opts.pactsDir ?? DEFAULT_PACTS_DIR);
|
|
204
|
+
const contracted: PactInteractionKey[] = [];
|
|
205
|
+
const pairs = new Set<string>();
|
|
206
|
+
|
|
207
|
+
for (const file of listPactFiles(pactsDir)) {
|
|
208
|
+
const pact = parsePactFile(file);
|
|
209
|
+
if (!pact) continue;
|
|
210
|
+
const interactions = interactionsFromPactFile(pact);
|
|
211
|
+
contracted.push(...interactions);
|
|
212
|
+
const consumer = pact.consumer?.name;
|
|
213
|
+
const provider = pact.provider?.name;
|
|
214
|
+
if (consumer && provider) {
|
|
215
|
+
pairs.add(pairKey(consumer, provider));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const ledger = readLedger(opts);
|
|
220
|
+
|
|
221
|
+
let brokerVerifications: BrokerVerification[] | undefined;
|
|
222
|
+
if (opts.broker) {
|
|
223
|
+
brokerVerifications = await fetchBrokerVerifications(opts.broker, [...pairs].map((p) => {
|
|
224
|
+
const [consumer, provider] = p.split('::');
|
|
225
|
+
return { consumer: consumer!, provider: provider! };
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return computeAuditMatrix({
|
|
230
|
+
contracted,
|
|
231
|
+
ledger,
|
|
232
|
+
brokerVerifications,
|
|
233
|
+
windowDays: opts.windowDays,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Sync audit without broker (backward compatible for tests). */
|
|
238
|
+
export function runAuditSync(opts: Omit<AuditOptions, 'broker'> = {}): AuditMatrix {
|
|
239
|
+
const pactsDir = path.resolve(process.cwd(), opts.pactsDir ?? DEFAULT_PACTS_DIR);
|
|
240
|
+
const contracted: PactInteractionKey[] = [];
|
|
241
|
+
for (const file of listPactFiles(pactsDir)) {
|
|
242
|
+
const pact = parsePactFile(file);
|
|
243
|
+
if (pact) contracted.push(...interactionsFromPactFile(pact));
|
|
244
|
+
}
|
|
245
|
+
const ledger = readLedger(opts);
|
|
246
|
+
return computeAuditMatrix({
|
|
247
|
+
contracted,
|
|
248
|
+
ledger,
|
|
249
|
+
windowDays: opts.windowDays,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { installAutoWrap } from './auto-wrap.js';
|
|
6
|
+
import { readLedger } from './ledger.js';
|
|
7
|
+
|
|
8
|
+
// We can't reuse the patched-once prototypes between tests cleanly —
|
|
9
|
+
// monkey-patching is by design a one-shot operation. So each test defines
|
|
10
|
+
// its own fresh fake classes, then installs the auto-wrap against them.
|
|
11
|
+
|
|
12
|
+
function makeFakeMessagePact() {
|
|
13
|
+
return class FakeMessageConsumerPact {
|
|
14
|
+
config = { consumer: 'A', provider: 'B' };
|
|
15
|
+
async verify(handler: (m: unknown) => Promise<unknown>): Promise<unknown> {
|
|
16
|
+
return handler({
|
|
17
|
+
contents: { x: 1 },
|
|
18
|
+
description: 'an evt',
|
|
19
|
+
providerStates: [{ name: 'state-x' }],
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeFakePactV3() {
|
|
26
|
+
return class FakePactV3 {
|
|
27
|
+
opts = { consumer: 'Web', provider: 'Catalog' };
|
|
28
|
+
added: Array<{ uponReceiving: string; states?: Array<{ description: string }> }> = [];
|
|
29
|
+
addInteraction(interaction: {
|
|
30
|
+
uponReceiving: string;
|
|
31
|
+
states?: Array<{ description: string }>;
|
|
32
|
+
}) {
|
|
33
|
+
this.added.push(interaction);
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
async executeTest<T>(
|
|
37
|
+
fn: (s: { url: string; port: number }) => Promise<T>,
|
|
38
|
+
): Promise<T | undefined> {
|
|
39
|
+
return fn({ url: 'http://localhost', port: 0 });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let workDir: string;
|
|
45
|
+
let originalCwd: string;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
originalCwd = process.cwd();
|
|
49
|
+
workDir = mkdtempSync(path.join(tmpdir(), 'autotel-pact-auto-'));
|
|
50
|
+
process.chdir(workDir);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
process.chdir(originalCwd);
|
|
55
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
56
|
+
delete process.env.AUTOTEL_PACT_RUN_ID;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('auto-wrap', () => {
|
|
60
|
+
it('patches MessageConsumerPact.prototype.verify to emit a ledger entry', async () => {
|
|
61
|
+
process.env.AUTOTEL_PACT_RUN_ID = 'r-auto-msg';
|
|
62
|
+
const Pact = makeFakeMessagePact();
|
|
63
|
+
installAutoWrap({
|
|
64
|
+
MessageConsumerPact: Pact as unknown as { prototype: Record<string | symbol, unknown> },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const pact = new Pact();
|
|
68
|
+
await pact.verify(async () => 'handled');
|
|
69
|
+
|
|
70
|
+
const entries = readLedger({ runId: 'r-auto-msg' });
|
|
71
|
+
expect(entries).toHaveLength(1);
|
|
72
|
+
expect(entries[0]).toMatchObject({
|
|
73
|
+
consumer: 'A',
|
|
74
|
+
provider: 'B',
|
|
75
|
+
interaction: 'an evt',
|
|
76
|
+
states: ['state-x'],
|
|
77
|
+
kind: 'message',
|
|
78
|
+
outcome: 'passed',
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('patches PactV3 so addInteraction + executeTest emits ledger entries', async () => {
|
|
83
|
+
process.env.AUTOTEL_PACT_RUN_ID = 'r-auto-http';
|
|
84
|
+
const Pact = makeFakePactV3();
|
|
85
|
+
installAutoWrap({
|
|
86
|
+
PactV3: Pact as unknown as { prototype: Record<string | symbol, unknown> },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const pact = new Pact();
|
|
90
|
+
pact.addInteraction({
|
|
91
|
+
uponReceiving: 'get widgets',
|
|
92
|
+
states: [{ description: 'widgets exist' }],
|
|
93
|
+
});
|
|
94
|
+
await pact.executeTest(async () => {});
|
|
95
|
+
|
|
96
|
+
const entries = readLedger({ runId: 'r-auto-http' });
|
|
97
|
+
expect(entries).toHaveLength(1);
|
|
98
|
+
expect(entries[0]).toMatchObject({
|
|
99
|
+
consumer: 'Web',
|
|
100
|
+
provider: 'Catalog',
|
|
101
|
+
interaction: 'get widgets',
|
|
102
|
+
states: ['widgets exist'],
|
|
103
|
+
kind: 'http',
|
|
104
|
+
outcome: 'passed',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('records failed outcome when the test body throws', async () => {
|
|
109
|
+
process.env.AUTOTEL_PACT_RUN_ID = 'r-auto-fail';
|
|
110
|
+
const Pact = makeFakePactV3();
|
|
111
|
+
installAutoWrap({
|
|
112
|
+
PactV3: Pact as unknown as { prototype: Record<string | symbol, unknown> },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const pact = new Pact();
|
|
116
|
+
pact.addInteraction({ uponReceiving: 'broken' });
|
|
117
|
+
|
|
118
|
+
await expect(
|
|
119
|
+
pact.executeTest(async () => {
|
|
120
|
+
throw new Error('assertion failed');
|
|
121
|
+
}),
|
|
122
|
+
).rejects.toThrow('assertion failed');
|
|
123
|
+
|
|
124
|
+
const entries = readLedger({ runId: 'r-auto-fail' });
|
|
125
|
+
expect(entries[0]).toMatchObject({ outcome: 'failed', error: 'assertion failed' });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('is idempotent — installing twice on the same prototype does not double-wrap', async () => {
|
|
129
|
+
process.env.AUTOTEL_PACT_RUN_ID = 'r-auto-idem';
|
|
130
|
+
const Pact = makeFakeMessagePact();
|
|
131
|
+
installAutoWrap({
|
|
132
|
+
MessageConsumerPact: Pact as unknown as { prototype: Record<string | symbol, unknown> },
|
|
133
|
+
});
|
|
134
|
+
installAutoWrap({
|
|
135
|
+
MessageConsumerPact: Pact as unknown as { prototype: Record<string | symbol, unknown> },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const pact = new Pact();
|
|
139
|
+
await pact.verify(async () => {});
|
|
140
|
+
|
|
141
|
+
const entries = readLedger({ runId: 'r-auto-idem' });
|
|
142
|
+
expect(entries).toHaveLength(1); // not 2
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('skips patching when no recognised ctors are present in the module', () => {
|
|
146
|
+
// Empty module is a successful no-op — nothing crashes, nothing patched.
|
|
147
|
+
expect(installAutoWrap({})).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|