predicate-claw 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/.github/workflows/release.yml +76 -0
- package/.github/workflows/tests.yml +34 -0
- package/.markdownlint.yaml +5 -0
- package/.pre-commit-config.yaml +100 -0
- package/README.md +405 -0
- package/dist/src/adapter.d.ts +17 -0
- package/dist/src/adapter.js +36 -0
- package/dist/src/authority-client.d.ts +21 -0
- package/dist/src/authority-client.js +22 -0
- package/dist/src/circuit-breaker.d.ts +86 -0
- package/dist/src/circuit-breaker.js +174 -0
- package/dist/src/config.d.ts +8 -0
- package/dist/src/config.js +7 -0
- package/dist/src/control-plane-sync.d.ts +57 -0
- package/dist/src/control-plane-sync.js +99 -0
- package/dist/src/errors.d.ts +6 -0
- package/dist/src/errors.js +6 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +12 -0
- package/dist/src/non-web-evidence.d.ts +46 -0
- package/dist/src/non-web-evidence.js +54 -0
- package/dist/src/openclaw-hooks.d.ts +27 -0
- package/dist/src/openclaw-hooks.js +54 -0
- package/dist/src/openclaw-plugin-api.d.ts +18 -0
- package/dist/src/openclaw-plugin-api.js +17 -0
- package/dist/src/provider.d.ts +48 -0
- package/dist/src/provider.js +154 -0
- package/dist/src/runtime-integration.d.ts +20 -0
- package/dist/src/runtime-integration.js +43 -0
- package/dist/src/web-evidence.d.ts +48 -0
- package/dist/src/web-evidence.js +49 -0
- package/dist/tests/adapter.test.d.ts +1 -0
- package/dist/tests/adapter.test.js +63 -0
- package/dist/tests/audit-event-e2e.test.d.ts +1 -0
- package/dist/tests/audit-event-e2e.test.js +209 -0
- package/dist/tests/authority-client.test.d.ts +1 -0
- package/dist/tests/authority-client.test.js +46 -0
- package/dist/tests/circuit-breaker.test.d.ts +1 -0
- package/dist/tests/circuit-breaker.test.js +200 -0
- package/dist/tests/control-plane-sync.test.d.ts +1 -0
- package/dist/tests/control-plane-sync.test.js +90 -0
- package/dist/tests/hack-vs-fix-demo.test.d.ts +1 -0
- package/dist/tests/hack-vs-fix-demo.test.js +36 -0
- package/dist/tests/jwks-rotation.test.d.ts +1 -0
- package/dist/tests/jwks-rotation.test.js +232 -0
- package/dist/tests/load-latency.test.d.ts +1 -0
- package/dist/tests/load-latency.test.js +175 -0
- package/dist/tests/multi-tenant-isolation.test.d.ts +1 -0
- package/dist/tests/multi-tenant-isolation.test.js +146 -0
- package/dist/tests/non-web-evidence.test.d.ts +1 -0
- package/dist/tests/non-web-evidence.test.js +139 -0
- package/dist/tests/openclaw-hooks.test.d.ts +1 -0
- package/dist/tests/openclaw-hooks.test.js +38 -0
- package/dist/tests/openclaw-plugin-api.test.d.ts +1 -0
- package/dist/tests/openclaw-plugin-api.test.js +40 -0
- package/dist/tests/provider.test.d.ts +1 -0
- package/dist/tests/provider.test.js +190 -0
- package/dist/tests/runtime-integration.test.d.ts +1 -0
- package/dist/tests/runtime-integration.test.js +57 -0
- package/dist/tests/web-evidence.test.d.ts +1 -0
- package/dist/tests/web-evidence.test.js +89 -0
- package/docs/MIGRATION_GUIDE.md +405 -0
- package/docs/OPERATIONAL_RUNBOOK.md +389 -0
- package/docs/PRODUCTION_READINESS.md +134 -0
- package/docs/SLO_THRESHOLDS.md +193 -0
- package/examples/README.md +171 -0
- package/examples/docker/Dockerfile.test +16 -0
- package/examples/docker/README.md +48 -0
- package/examples/docker/docker-compose.test.yml +16 -0
- package/examples/non-web-evidence-demo.ts +184 -0
- package/examples/openclaw-plugin-smoke/index.ts +30 -0
- package/examples/openclaw-plugin-smoke/openclaw.plugin.json +11 -0
- package/examples/openclaw-plugin-smoke/package.json +9 -0
- package/examples/openclaw_integration_example.py +41 -0
- package/examples/policy/README.md +165 -0
- package/examples/policy/approved-hosts.yaml +137 -0
- package/examples/policy/dev-workflow.yaml +206 -0
- package/examples/policy/policy.example.yaml +17 -0
- package/examples/policy/production-strict.yaml +97 -0
- package/examples/policy/sensitive-paths.yaml +114 -0
- package/examples/policy/source-trust.yaml +129 -0
- package/examples/policy/workspace-isolation.yaml +51 -0
- package/examples/runtime_registry_example.py +75 -0
- package/package.json +27 -0
- package/pyproject.toml +41 -0
- package/src/adapter.ts +45 -0
- package/src/authority-client.ts +50 -0
- package/src/circuit-breaker.ts +245 -0
- package/src/config.ts +15 -0
- package/src/control-plane-sync.ts +159 -0
- package/src/errors.ts +5 -0
- package/src/index.ts +12 -0
- package/src/non-web-evidence.ts +116 -0
- package/src/openclaw-hooks.ts +76 -0
- package/src/openclaw-plugin-api.ts +51 -0
- package/src/openclaw_predicate_provider/__init__.py +16 -0
- package/src/openclaw_predicate_provider/__main__.py +5 -0
- package/src/openclaw_predicate_provider/adapter.py +84 -0
- package/src/openclaw_predicate_provider/agentidentity_backend.py +78 -0
- package/src/openclaw_predicate_provider/cli.py +160 -0
- package/src/openclaw_predicate_provider/config.py +42 -0
- package/src/openclaw_predicate_provider/errors.py +13 -0
- package/src/openclaw_predicate_provider/integrations/__init__.py +5 -0
- package/src/openclaw_predicate_provider/integrations/openclaw_runtime.py +74 -0
- package/src/openclaw_predicate_provider/models.py +19 -0
- package/src/openclaw_predicate_provider/openclaw_hooks.py +75 -0
- package/src/openclaw_predicate_provider/provider.py +69 -0
- package/src/openclaw_predicate_provider/py.typed +1 -0
- package/src/openclaw_predicate_provider/sidecar.py +59 -0
- package/src/provider.ts +220 -0
- package/src/runtime-integration.ts +68 -0
- package/src/web-evidence.ts +95 -0
- package/tests/adapter.test.ts +76 -0
- package/tests/audit-event-e2e.test.ts +258 -0
- package/tests/authority-client.test.ts +52 -0
- package/tests/circuit-breaker.test.ts +266 -0
- package/tests/conftest.py +9 -0
- package/tests/control-plane-sync.test.ts +114 -0
- package/tests/hack-vs-fix-demo.test.ts +44 -0
- package/tests/jwks-rotation.test.ts +274 -0
- package/tests/load-latency.test.ts +214 -0
- package/tests/multi-tenant-isolation.test.ts +183 -0
- package/tests/non-web-evidence.test.ts +168 -0
- package/tests/openclaw-hooks.test.ts +46 -0
- package/tests/openclaw-plugin-api.test.ts +50 -0
- package/tests/provider.test.ts +227 -0
- package/tests/runtime-integration.test.ts +70 -0
- package/tests/test_adapter.py +46 -0
- package/tests/test_cli.py +26 -0
- package/tests/test_openclaw_hooks.py +53 -0
- package/tests/test_provider.py +59 -0
- package/tests/test_runtime_integration.py +77 -0
- package/tests/test_sidecar_client.py +198 -0
- package/tests/web-evidence.test.ts +113 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type DecisionAuditExporter,
|
|
4
|
+
type DecisionTelemetryEvent,
|
|
5
|
+
GuardedProvider,
|
|
6
|
+
ActionDeniedError,
|
|
7
|
+
} from "../src/provider.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* End-to-end audit event visibility tests.
|
|
11
|
+
*
|
|
12
|
+
* These tests verify that decision events flow correctly from the provider
|
|
13
|
+
* through to audit exporters in a format compatible with control-plane
|
|
14
|
+
* audit pipelines.
|
|
15
|
+
*/
|
|
16
|
+
describe("audit event visibility (e2e)", () => {
|
|
17
|
+
it("exports allow decisions with full context to audit sink", async () => {
|
|
18
|
+
const exportedEvents: DecisionTelemetryEvent[] = [];
|
|
19
|
+
|
|
20
|
+
const mockClient = {
|
|
21
|
+
authorize: vi.fn().mockResolvedValue({
|
|
22
|
+
allow: true,
|
|
23
|
+
reason: "policy_matched",
|
|
24
|
+
mandateId: "mandate-12345",
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const auditExporter: DecisionAuditExporter = {
|
|
29
|
+
exportDecision: async (event) => {
|
|
30
|
+
exportedEvents.push(event);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const provider = new GuardedProvider({
|
|
35
|
+
principal: "agent:e2e-test-agent",
|
|
36
|
+
authorityClient: mockClient,
|
|
37
|
+
auditExporter,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await provider.authorize({
|
|
41
|
+
action: "shell.execute",
|
|
42
|
+
resource: "npm install lodash",
|
|
43
|
+
args: { cmd: "npm install lodash" },
|
|
44
|
+
context: {
|
|
45
|
+
tenant_id: "tenant-prod",
|
|
46
|
+
session_id: "sess-e2e-001",
|
|
47
|
+
user_id: "user-developer",
|
|
48
|
+
trace_id: "trace-e2e-xyz",
|
|
49
|
+
source: "trusted_ui",
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Verify event was exported
|
|
54
|
+
expect(exportedEvents).toHaveLength(1);
|
|
55
|
+
|
|
56
|
+
const event = exportedEvents[0];
|
|
57
|
+
|
|
58
|
+
// Verify control-plane compatible fields
|
|
59
|
+
expect(event.principal).toBe("agent:e2e-test-agent");
|
|
60
|
+
expect(event.action).toBe("shell.execute");
|
|
61
|
+
expect(event.resource).toBe("npm install lodash");
|
|
62
|
+
expect(event.outcome).toBe("allow");
|
|
63
|
+
expect(event.reason).toBe("policy_matched");
|
|
64
|
+
expect(event.mandateId).toBe("mandate-12345");
|
|
65
|
+
|
|
66
|
+
// Verify tenant/session context
|
|
67
|
+
expect(event.tenantId).toBe("tenant-prod");
|
|
68
|
+
expect(event.sessionId).toBe("sess-e2e-001");
|
|
69
|
+
expect(event.userId).toBe("user-developer");
|
|
70
|
+
expect(event.traceId).toBe("trace-e2e-xyz");
|
|
71
|
+
expect(event.source).toBe("trusted_ui");
|
|
72
|
+
|
|
73
|
+
// Verify timestamp is ISO format
|
|
74
|
+
expect(event.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("exports deny decisions with redacted sensitive resources", async () => {
|
|
78
|
+
const exportedEvents: DecisionTelemetryEvent[] = [];
|
|
79
|
+
|
|
80
|
+
const mockClient = {
|
|
81
|
+
authorize: vi.fn().mockResolvedValue({
|
|
82
|
+
allow: false,
|
|
83
|
+
reason: "sensitive_path_blocked",
|
|
84
|
+
}),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const auditExporter: DecisionAuditExporter = {
|
|
88
|
+
exportDecision: async (event) => {
|
|
89
|
+
exportedEvents.push(event);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const provider = new GuardedProvider({
|
|
94
|
+
principal: "agent:sensitive-test",
|
|
95
|
+
authorityClient: mockClient,
|
|
96
|
+
auditExporter,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
provider.authorize({
|
|
101
|
+
action: "fs.read",
|
|
102
|
+
resource: "/home/user/.ssh/id_rsa",
|
|
103
|
+
args: { path: "/home/user/.ssh/id_rsa" },
|
|
104
|
+
context: { tenant_id: "tenant-sec" },
|
|
105
|
+
}),
|
|
106
|
+
).rejects.toThrow(ActionDeniedError);
|
|
107
|
+
|
|
108
|
+
expect(exportedEvents).toHaveLength(1);
|
|
109
|
+
|
|
110
|
+
const event = exportedEvents[0];
|
|
111
|
+
expect(event.outcome).toBe("deny");
|
|
112
|
+
expect(event.reason).toBe("sensitive_path_blocked");
|
|
113
|
+
// Resource should be redacted for sensitive paths
|
|
114
|
+
expect(event.resource).toBe("[REDACTED]");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("exports error events when sidecar is unavailable", async () => {
|
|
118
|
+
const exportedEvents: DecisionTelemetryEvent[] = [];
|
|
119
|
+
|
|
120
|
+
const mockClient = {
|
|
121
|
+
authorize: vi.fn().mockRejectedValue(new Error("Connection refused")),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const auditExporter: DecisionAuditExporter = {
|
|
125
|
+
exportDecision: async (event) => {
|
|
126
|
+
exportedEvents.push(event);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const provider = new GuardedProvider({
|
|
131
|
+
principal: "agent:error-test",
|
|
132
|
+
authorityClient: mockClient,
|
|
133
|
+
auditExporter,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await expect(
|
|
137
|
+
provider.authorize({
|
|
138
|
+
action: "net.http",
|
|
139
|
+
resource: "https://api.example.com",
|
|
140
|
+
args: { url: "https://api.example.com" },
|
|
141
|
+
context: { tenant_id: "tenant-error" },
|
|
142
|
+
}),
|
|
143
|
+
).rejects.toThrow();
|
|
144
|
+
|
|
145
|
+
expect(exportedEvents).toHaveLength(1);
|
|
146
|
+
|
|
147
|
+
const event = exportedEvents[0];
|
|
148
|
+
expect(event.outcome).toBe("error");
|
|
149
|
+
expect(event.tenantId).toBe("tenant-error");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("handles audit exporter failures gracefully (best-effort)", async () => {
|
|
153
|
+
let callCount = 0;
|
|
154
|
+
|
|
155
|
+
const mockClient = {
|
|
156
|
+
authorize: vi.fn().mockResolvedValue({ allow: true }),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const failingExporter: DecisionAuditExporter = {
|
|
160
|
+
exportDecision: async () => {
|
|
161
|
+
callCount++;
|
|
162
|
+
throw new Error("Audit sink unavailable");
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const provider = new GuardedProvider({
|
|
167
|
+
principal: "agent:failing-audit",
|
|
168
|
+
authorityClient: mockClient,
|
|
169
|
+
auditExporter: failingExporter,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Should not throw even though exporter fails
|
|
173
|
+
const result = await provider.authorize({
|
|
174
|
+
action: "fs.read",
|
|
175
|
+
resource: "/workspace/safe-file.txt",
|
|
176
|
+
args: { path: "/workspace/safe-file.txt" },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Authorization should succeed despite audit failure
|
|
180
|
+
expect(result).toBeNull(); // No mandate ID returned in this case
|
|
181
|
+
expect(callCount).toBe(1); // Exporter was called
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("chains multiple decision events with consistent trace context", async () => {
|
|
185
|
+
const exportedEvents: DecisionTelemetryEvent[] = [];
|
|
186
|
+
|
|
187
|
+
const mockClient = {
|
|
188
|
+
authorize: vi
|
|
189
|
+
.fn()
|
|
190
|
+
.mockResolvedValueOnce({ allow: true, mandateId: "m1" })
|
|
191
|
+
.mockResolvedValueOnce({ allow: true, mandateId: "m2" })
|
|
192
|
+
.mockResolvedValueOnce({ allow: false, reason: "rate_limited" }),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const auditExporter: DecisionAuditExporter = {
|
|
196
|
+
exportDecision: async (event) => {
|
|
197
|
+
exportedEvents.push(event);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const provider = new GuardedProvider({
|
|
202
|
+
principal: "agent:chained-ops",
|
|
203
|
+
authorityClient: mockClient,
|
|
204
|
+
auditExporter,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const sharedContext = {
|
|
208
|
+
tenant_id: "tenant-chain",
|
|
209
|
+
session_id: "sess-chain",
|
|
210
|
+
trace_id: "trace-chain-root",
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// First operation - allowed
|
|
214
|
+
await provider.authorize({
|
|
215
|
+
action: "fs.read",
|
|
216
|
+
resource: "/workspace/config.json",
|
|
217
|
+
args: { path: "/workspace/config.json" },
|
|
218
|
+
context: sharedContext,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Second operation - allowed
|
|
222
|
+
await provider.authorize({
|
|
223
|
+
action: "net.http",
|
|
224
|
+
resource: "https://api.internal/fetch",
|
|
225
|
+
args: { url: "https://api.internal/fetch" },
|
|
226
|
+
context: sharedContext,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Third operation - denied
|
|
230
|
+
try {
|
|
231
|
+
await provider.authorize({
|
|
232
|
+
action: "shell.execute",
|
|
233
|
+
resource: "curl external.com",
|
|
234
|
+
args: { cmd: "curl external.com" },
|
|
235
|
+
context: sharedContext,
|
|
236
|
+
});
|
|
237
|
+
} catch {
|
|
238
|
+
// Expected denial
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
expect(exportedEvents).toHaveLength(3);
|
|
242
|
+
|
|
243
|
+
// All events should share the same trace context
|
|
244
|
+
for (const event of exportedEvents) {
|
|
245
|
+
expect(event.tenantId).toBe("tenant-chain");
|
|
246
|
+
expect(event.sessionId).toBe("sess-chain");
|
|
247
|
+
expect(event.traceId).toBe("trace-chain-root");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Verify outcomes
|
|
251
|
+
expect(exportedEvents[0].outcome).toBe("allow");
|
|
252
|
+
expect(exportedEvents[0].mandateId).toBe("m1");
|
|
253
|
+
expect(exportedEvents[1].outcome).toBe("allow");
|
|
254
|
+
expect(exportedEvents[1].mandateId).toBe("m2");
|
|
255
|
+
expect(exportedEvents[2].outcome).toBe("deny");
|
|
256
|
+
expect(exportedEvents[2].reason).toBe("rate_limited");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createAuthorityAdapter } from "../src/authority-client.js";
|
|
3
|
+
|
|
4
|
+
describe("createAuthorityAdapter", () => {
|
|
5
|
+
it("maps SDK allow decisions", async () => {
|
|
6
|
+
const adapter = createAuthorityAdapter({
|
|
7
|
+
authorize: async () => ({
|
|
8
|
+
allowed: true,
|
|
9
|
+
reason: "allowed",
|
|
10
|
+
mandate_id: "mnd_ok",
|
|
11
|
+
}),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const decision = await adapter.authorize({
|
|
15
|
+
principal: "agent:openclaw-local",
|
|
16
|
+
action: "shell.execute",
|
|
17
|
+
resource: "echo hi",
|
|
18
|
+
intent_hash: "ih",
|
|
19
|
+
labels: [],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(decision).toEqual({
|
|
23
|
+
allow: true,
|
|
24
|
+
reason: "allowed",
|
|
25
|
+
mandateId: "mnd_ok",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("maps SDK deny decisions", async () => {
|
|
30
|
+
const adapter = createAuthorityAdapter({
|
|
31
|
+
authorize: async () => ({
|
|
32
|
+
allowed: false,
|
|
33
|
+
reason: "explicit_deny",
|
|
34
|
+
mandate_id: undefined,
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const decision = await adapter.authorize({
|
|
39
|
+
principal: "agent:openclaw-local",
|
|
40
|
+
action: "fs.read",
|
|
41
|
+
resource: "/etc/passwd",
|
|
42
|
+
intent_hash: "ih",
|
|
43
|
+
labels: ["source:untrusted_dm"],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(decision).toEqual({
|
|
47
|
+
allow: false,
|
|
48
|
+
reason: "explicit_deny",
|
|
49
|
+
mandateId: undefined,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
calculateBackoff,
|
|
4
|
+
CircuitBreaker,
|
|
5
|
+
CircuitOpenError,
|
|
6
|
+
defaultBackoffConfig,
|
|
7
|
+
defaultCircuitBreakerConfig,
|
|
8
|
+
withCircuitBreaker,
|
|
9
|
+
type CircuitState,
|
|
10
|
+
} from "../src/circuit-breaker.js";
|
|
11
|
+
|
|
12
|
+
describe("CircuitBreaker", () => {
|
|
13
|
+
it("starts in closed state", () => {
|
|
14
|
+
const breaker = new CircuitBreaker(defaultCircuitBreakerConfig);
|
|
15
|
+
expect(breaker.getState()).toBe("closed");
|
|
16
|
+
expect(breaker.allowRequest()).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("opens after reaching failure threshold", () => {
|
|
20
|
+
const breaker = new CircuitBreaker({
|
|
21
|
+
...defaultCircuitBreakerConfig,
|
|
22
|
+
failureThreshold: 3,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
breaker.recordFailure();
|
|
26
|
+
expect(breaker.getState()).toBe("closed");
|
|
27
|
+
|
|
28
|
+
breaker.recordFailure();
|
|
29
|
+
expect(breaker.getState()).toBe("closed");
|
|
30
|
+
|
|
31
|
+
breaker.recordFailure();
|
|
32
|
+
expect(breaker.getState()).toBe("open");
|
|
33
|
+
expect(breaker.allowRequest()).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("transitions to half-open after reset timeout", async () => {
|
|
37
|
+
const breaker = new CircuitBreaker({
|
|
38
|
+
...defaultCircuitBreakerConfig,
|
|
39
|
+
failureThreshold: 1,
|
|
40
|
+
resetTimeoutMs: 50,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
breaker.recordFailure();
|
|
44
|
+
expect(breaker.getState()).toBe("open");
|
|
45
|
+
expect(breaker.allowRequest()).toBe(false);
|
|
46
|
+
|
|
47
|
+
// Wait for reset timeout
|
|
48
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
49
|
+
|
|
50
|
+
expect(breaker.allowRequest()).toBe(true);
|
|
51
|
+
expect(breaker.getState()).toBe("half_open");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("closes after success threshold in half-open", async () => {
|
|
55
|
+
const breaker = new CircuitBreaker({
|
|
56
|
+
...defaultCircuitBreakerConfig,
|
|
57
|
+
failureThreshold: 1,
|
|
58
|
+
resetTimeoutMs: 10,
|
|
59
|
+
successThreshold: 2,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
breaker.recordFailure();
|
|
63
|
+
expect(breaker.getState()).toBe("open");
|
|
64
|
+
|
|
65
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
66
|
+
breaker.allowRequest(); // Triggers half-open
|
|
67
|
+
|
|
68
|
+
expect(breaker.getState()).toBe("half_open");
|
|
69
|
+
|
|
70
|
+
breaker.recordSuccess();
|
|
71
|
+
expect(breaker.getState()).toBe("half_open");
|
|
72
|
+
|
|
73
|
+
breaker.recordSuccess();
|
|
74
|
+
expect(breaker.getState()).toBe("closed");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reopens on failure in half-open state", async () => {
|
|
78
|
+
const breaker = new CircuitBreaker({
|
|
79
|
+
...defaultCircuitBreakerConfig,
|
|
80
|
+
failureThreshold: 1,
|
|
81
|
+
resetTimeoutMs: 10,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
breaker.recordFailure();
|
|
85
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
86
|
+
breaker.allowRequest();
|
|
87
|
+
|
|
88
|
+
expect(breaker.getState()).toBe("half_open");
|
|
89
|
+
|
|
90
|
+
breaker.recordFailure();
|
|
91
|
+
expect(breaker.getState()).toBe("open");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("resets failure count on success in closed state", () => {
|
|
95
|
+
const breaker = new CircuitBreaker({
|
|
96
|
+
...defaultCircuitBreakerConfig,
|
|
97
|
+
failureThreshold: 3,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
breaker.recordFailure();
|
|
101
|
+
breaker.recordFailure();
|
|
102
|
+
breaker.recordSuccess();
|
|
103
|
+
breaker.recordFailure();
|
|
104
|
+
breaker.recordFailure();
|
|
105
|
+
|
|
106
|
+
// Should still be closed because success reset the count
|
|
107
|
+
expect(breaker.getState()).toBe("closed");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("tracks metrics correctly", () => {
|
|
111
|
+
const breaker = new CircuitBreaker({
|
|
112
|
+
...defaultCircuitBreakerConfig,
|
|
113
|
+
failureThreshold: 2,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
breaker.recordSuccess();
|
|
117
|
+
breaker.recordSuccess();
|
|
118
|
+
breaker.recordFailure();
|
|
119
|
+
breaker.recordFailure();
|
|
120
|
+
breaker.allowRequest(); // Should be rejected
|
|
121
|
+
|
|
122
|
+
const metrics = breaker.getMetrics();
|
|
123
|
+
expect(metrics.totalSuccesses).toBe(2);
|
|
124
|
+
expect(metrics.totalFailures).toBe(2);
|
|
125
|
+
expect(metrics.totalRejections).toBe(1);
|
|
126
|
+
expect(metrics.state).toBe("open");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("calls onStateChange callback", () => {
|
|
130
|
+
const stateChanges: Array<{ from: CircuitState; to: CircuitState }> = [];
|
|
131
|
+
|
|
132
|
+
const breaker = new CircuitBreaker({
|
|
133
|
+
...defaultCircuitBreakerConfig,
|
|
134
|
+
failureThreshold: 1,
|
|
135
|
+
onStateChange: (from, to) => stateChanges.push({ from, to }),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
breaker.recordFailure();
|
|
139
|
+
expect(stateChanges).toHaveLength(1);
|
|
140
|
+
expect(stateChanges[0]).toEqual({ from: "closed", to: "open" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("can be manually reset", () => {
|
|
144
|
+
const breaker = new CircuitBreaker({
|
|
145
|
+
...defaultCircuitBreakerConfig,
|
|
146
|
+
failureThreshold: 1,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
breaker.recordFailure();
|
|
150
|
+
expect(breaker.getState()).toBe("open");
|
|
151
|
+
|
|
152
|
+
breaker.reset();
|
|
153
|
+
expect(breaker.getState()).toBe("closed");
|
|
154
|
+
expect(breaker.allowRequest()).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("calculateBackoff", () => {
|
|
159
|
+
it("calculates exponential backoff", () => {
|
|
160
|
+
const config = { ...defaultBackoffConfig, jitterFactor: 0 };
|
|
161
|
+
|
|
162
|
+
expect(calculateBackoff(0, config)).toBe(100);
|
|
163
|
+
expect(calculateBackoff(1, config)).toBe(200);
|
|
164
|
+
expect(calculateBackoff(2, config)).toBe(400);
|
|
165
|
+
expect(calculateBackoff(3, config)).toBe(800);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("respects max backoff", () => {
|
|
169
|
+
const config = { ...defaultBackoffConfig, jitterFactor: 0, maxMs: 500 };
|
|
170
|
+
|
|
171
|
+
expect(calculateBackoff(0, config)).toBe(100);
|
|
172
|
+
expect(calculateBackoff(1, config)).toBe(200);
|
|
173
|
+
expect(calculateBackoff(2, config)).toBe(400);
|
|
174
|
+
expect(calculateBackoff(3, config)).toBe(500); // Capped at max
|
|
175
|
+
expect(calculateBackoff(10, config)).toBe(500);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("adds jitter within bounds", () => {
|
|
179
|
+
const config = { ...defaultBackoffConfig, jitterFactor: 0.5 };
|
|
180
|
+
const results = new Set<number>();
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < 20; i++) {
|
|
183
|
+
results.add(calculateBackoff(0, config));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// With jitter, we should get varied results
|
|
187
|
+
expect(results.size).toBeGreaterThan(1);
|
|
188
|
+
|
|
189
|
+
// All results should be within expected range (100 +/- 50)
|
|
190
|
+
for (const result of results) {
|
|
191
|
+
expect(result).toBeGreaterThanOrEqual(50);
|
|
192
|
+
expect(result).toBeLessThanOrEqual(150);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("withCircuitBreaker", () => {
|
|
198
|
+
it("executes function and records success", async () => {
|
|
199
|
+
const breaker = new CircuitBreaker(defaultCircuitBreakerConfig);
|
|
200
|
+
const fn = vi.fn().mockResolvedValue("result");
|
|
201
|
+
|
|
202
|
+
const result = await withCircuitBreaker(breaker, fn);
|
|
203
|
+
|
|
204
|
+
expect(result).toBe("result");
|
|
205
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
206
|
+
expect(breaker.getMetrics().totalSuccesses).toBe(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("throws CircuitOpenError when circuit is open", async () => {
|
|
210
|
+
const breaker = new CircuitBreaker({
|
|
211
|
+
...defaultCircuitBreakerConfig,
|
|
212
|
+
failureThreshold: 1,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
breaker.recordFailure();
|
|
216
|
+
expect(breaker.getState()).toBe("open");
|
|
217
|
+
|
|
218
|
+
const fn = vi.fn().mockResolvedValue("result");
|
|
219
|
+
|
|
220
|
+
await expect(withCircuitBreaker(breaker, fn)).rejects.toThrow(
|
|
221
|
+
CircuitOpenError,
|
|
222
|
+
);
|
|
223
|
+
expect(fn).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("retries with backoff on failure", async () => {
|
|
227
|
+
const breaker = new CircuitBreaker({
|
|
228
|
+
...defaultCircuitBreakerConfig,
|
|
229
|
+
failureThreshold: 10,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
let attempts = 0;
|
|
233
|
+
const fn = vi.fn().mockImplementation(() => {
|
|
234
|
+
attempts++;
|
|
235
|
+
if (attempts < 3) {
|
|
236
|
+
return Promise.reject(new Error("fail"));
|
|
237
|
+
}
|
|
238
|
+
return Promise.resolve("success");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const result = await withCircuitBreaker(breaker, fn, {
|
|
242
|
+
maxRetries: 3,
|
|
243
|
+
backoffConfig: { ...defaultBackoffConfig, initialMs: 10, jitterFactor: 0 },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(result).toBe("success");
|
|
247
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("respects isFailure predicate", async () => {
|
|
251
|
+
const breaker = new CircuitBreaker(defaultCircuitBreakerConfig);
|
|
252
|
+
|
|
253
|
+
const businessError = new Error("business_error");
|
|
254
|
+
const fn = vi.fn().mockRejectedValue(businessError);
|
|
255
|
+
|
|
256
|
+
// Business errors should not trigger circuit breaker
|
|
257
|
+
await expect(
|
|
258
|
+
withCircuitBreaker(breaker, fn, {
|
|
259
|
+
isFailure: (e) => !(e instanceof Error && e.message === "business_error"),
|
|
260
|
+
}),
|
|
261
|
+
).rejects.toThrow("business_error");
|
|
262
|
+
|
|
263
|
+
// No failures recorded
|
|
264
|
+
expect(breaker.getMetrics().totalFailures).toBe(0);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ControlPlaneSyncClient,
|
|
4
|
+
ControlPlaneSyncStatusTracker,
|
|
5
|
+
syncControlPlaneState,
|
|
6
|
+
} from "../src/control-plane-sync.js";
|
|
7
|
+
|
|
8
|
+
describe("ControlPlaneSyncClient", () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.unstubAllGlobals();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("pulls policy and revocation snapshots", async () => {
|
|
14
|
+
const fetchMock = vi.fn(async (input: string) => {
|
|
15
|
+
if (input.includes("/v1/policy/sync")) {
|
|
16
|
+
return {
|
|
17
|
+
ok: true,
|
|
18
|
+
status: 200,
|
|
19
|
+
json: async () => ({
|
|
20
|
+
version: "p-1",
|
|
21
|
+
cursor: "pc-1",
|
|
22
|
+
rules: [{ name: "allow-safe-read" }],
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (input.includes("/v1/revocations/sync")) {
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
status: 200,
|
|
30
|
+
json: async () => ({
|
|
31
|
+
version: "r-1",
|
|
32
|
+
cursor: "rc-1",
|
|
33
|
+
revoked: [{ type: "principal", id: "agent:deny" }],
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Unexpected URL: ${input}`);
|
|
38
|
+
});
|
|
39
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
40
|
+
|
|
41
|
+
const client = new ControlPlaneSyncClient({
|
|
42
|
+
baseUrl: "http://127.0.0.1:9000",
|
|
43
|
+
tenantId: "tenant-a",
|
|
44
|
+
});
|
|
45
|
+
const result = await syncControlPlaneState(client, {
|
|
46
|
+
policyCursor: "p0",
|
|
47
|
+
revocationCursor: "r0",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result.policy.version).toBe("p-1");
|
|
51
|
+
expect(result.revocations.version).toBe("r-1");
|
|
52
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("throws on non-OK sync responses", async () => {
|
|
56
|
+
vi.stubGlobal("fetch", async () => ({
|
|
57
|
+
ok: false,
|
|
58
|
+
status: 503,
|
|
59
|
+
json: async () => ({ error: "unavailable" }),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const client = new ControlPlaneSyncClient({
|
|
63
|
+
baseUrl: "http://127.0.0.1:9000",
|
|
64
|
+
tenantId: "tenant-a",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await expect(client.pullPolicySnapshot("c1")).rejects.toThrow(
|
|
68
|
+
"policy sync failed",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("flags policy version mismatch against pinned version", () => {
|
|
73
|
+
const statuses: Array<{ policyVersionMismatch: boolean; stale: boolean }> = [];
|
|
74
|
+
const tracker = new ControlPlaneSyncStatusTracker({
|
|
75
|
+
pinnedPolicyVersion: "p-expected",
|
|
76
|
+
staleAfterMs: 300000,
|
|
77
|
+
onStatus: (status) => {
|
|
78
|
+
statuses.push({
|
|
79
|
+
policyVersionMismatch: status.policyVersionMismatch,
|
|
80
|
+
stale: status.stale,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const status = tracker.recordSync(
|
|
86
|
+
{
|
|
87
|
+
policy: { version: "p-actual", cursor: "pc-1", rules: [] },
|
|
88
|
+
revocations: { version: "r-1", cursor: "rc-1", revoked: [] },
|
|
89
|
+
},
|
|
90
|
+
1000,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(status.policyVersionMismatch).toBe(true);
|
|
94
|
+
expect(status.stale).toBe(false);
|
|
95
|
+
expect(statuses).toEqual([{ policyVersionMismatch: true, stale: false }]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("reports stale sync state when sync age exceeds threshold", () => {
|
|
99
|
+
const tracker = new ControlPlaneSyncStatusTracker({
|
|
100
|
+
staleAfterMs: 5000,
|
|
101
|
+
});
|
|
102
|
+
tracker.recordSync(
|
|
103
|
+
{
|
|
104
|
+
policy: { version: "p-1", cursor: "pc-1", rules: [] },
|
|
105
|
+
revocations: { version: "r-1", cursor: "rc-1", revoked: [] },
|
|
106
|
+
},
|
|
107
|
+
1000,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const status = tracker.snapshot(7001);
|
|
111
|
+
expect(status.stale).toBe(true);
|
|
112
|
+
expect(status.syncAgeMs).toBe(6001);
|
|
113
|
+
});
|
|
114
|
+
});
|