@intentius/chant 0.65.0 → 0.66.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/dist/behaviour-delta.d.ts +181 -0
- package/dist/behaviour-delta.d.ts.map +1 -0
- package/dist/behaviour-http.d.ts +106 -0
- package/dist/behaviour-http.d.ts.map +1 -0
- package/dist/behaviour-overlay.d.ts +61 -0
- package/dist/behaviour-overlay.d.ts.map +1 -0
- package/dist/behaviour.d.ts +178 -3
- package/dist/behaviour.d.ts.map +1 -1
- package/dist/cli/handlers/scenario.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lifecycle/scenario-eval.d.ts +23 -5
- package/dist/lifecycle/scenario-eval.d.ts.map +1 -1
- package/dist/lifecycle/scenario.d.ts +45 -3
- package/dist/lifecycle/scenario.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +17 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +42 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.d.ts +207 -0
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -0
- package/dist/op/activities/reconcile.d.ts +7 -0
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/composites/behaviour-op.d.ts +57 -0
- package/dist/op/composites/behaviour-op.d.ts.map +1 -0
- package/dist/op/composites/index.d.ts +2 -0
- package/dist/op/composites/index.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/behaviour-delta.test.ts +331 -0
- package/src/behaviour-delta.ts +564 -0
- package/src/behaviour-http.test.ts +456 -0
- package/src/behaviour-http.ts +252 -0
- package/src/behaviour-overlay.test.ts +149 -0
- package/src/behaviour-overlay.ts +76 -0
- package/src/behaviour.test.ts +50 -0
- package/src/behaviour.ts +255 -3
- package/src/cli/handlers/scenario.test.ts +108 -0
- package/src/cli/handlers/scenario.ts +63 -16
- package/src/index.ts +2 -0
- package/src/lifecycle/scenario-cost.test.ts +183 -0
- package/src/lifecycle/scenario-eval.ts +133 -6
- package/src/lifecycle/scenario.ts +72 -4
- package/src/lifecycle/types.ts +17 -0
- package/src/lint/rules/op/ops012-activity-contract.test.ts +31 -0
- package/src/op/activities/activity-contracts.ts +49 -0
- package/src/op/activities/index.ts +24 -0
- package/src/op/activities/predict-behaviour.test.ts +255 -0
- package/src/op/activities/predict-behaviour.ts +468 -0
- package/src/op/activities/reconcile.ts +7 -2
- package/src/op/activity-contract-registry.test.ts +3 -0
- package/src/op/composites/behaviour-op.test.ts +56 -0
- package/src/op/composites/behaviour-op.ts +99 -0
- package/src/op/composites/index.ts +2 -0
- package/src/op/index.ts +2 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first engine adapter (#2359), against a fake wire.
|
|
3
|
+
*
|
|
4
|
+
* Three things are proven here and nowhere else. The wire mapping: each
|
|
5
|
+
* status and each way `fetch` can throw lands on the cause the contract fixes
|
|
6
|
+
* for it, and on no other. The token: it goes in the header, it is read from
|
|
7
|
+
* its own chain most specific first, and its value appears in no refusal of
|
|
8
|
+
* any of the four kinds even when the engine echoes it back. And the shape of
|
|
9
|
+
* what is sent: `POST`, the body byte for byte, a JSON content type, and no
|
|
10
|
+
* redirect followed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from "vitest";
|
|
14
|
+
import {
|
|
15
|
+
behaviourEngineFrom,
|
|
16
|
+
behaviourTokenFrom,
|
|
17
|
+
behaviourTokenVariables,
|
|
18
|
+
isBehaviourRefusalReport,
|
|
19
|
+
type BehaviourEngineEndpoint,
|
|
20
|
+
type BehaviourRefusalReport,
|
|
21
|
+
type BehaviourTransportOutcome,
|
|
22
|
+
} from "./behaviour";
|
|
23
|
+
import {
|
|
24
|
+
concealing,
|
|
25
|
+
httpBehaviourTransport,
|
|
26
|
+
httpStatusRefusal,
|
|
27
|
+
isHttpBehaviourAddress,
|
|
28
|
+
HTTP_BEHAVIOUR_TIMEOUT_MS,
|
|
29
|
+
} from "./behaviour-http";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A value no denylist knows. Not `sk-…`, not `ghp_…`, not `Bearer …`: those
|
|
33
|
+
* shapes are already blanked by `scrubEngineDetail` through `identity.ts`, so
|
|
34
|
+
* a token in one of them passes every leak test below with the adapter's own
|
|
35
|
+
* concealment deleted — which the first version of this file did, on an
|
|
36
|
+
* `sk-` value. What is being proven is that the *configured* token is
|
|
37
|
+
* concealed, whatever it looks like.
|
|
38
|
+
*/
|
|
39
|
+
const TOKEN = "acme-9f8e7d6c5b4a3210-not-for-print";
|
|
40
|
+
const ENDPOINT: BehaviourEngineEndpoint = {
|
|
41
|
+
value: "https://engine.example/v1/predict",
|
|
42
|
+
source: "CHANT_BEHAVIOUR_ENGINE",
|
|
43
|
+
};
|
|
44
|
+
const ENV = { CHANT_BEHAVIOUR_TOKEN: TOKEN };
|
|
45
|
+
const BODY = '{\n "request": "acme/v1",\n "traffic": "100 rps, p50"\n}\n';
|
|
46
|
+
|
|
47
|
+
/** One call the fake wire saw. */
|
|
48
|
+
interface Seen {
|
|
49
|
+
url: string;
|
|
50
|
+
method: string | undefined;
|
|
51
|
+
headers: Headers;
|
|
52
|
+
body: string;
|
|
53
|
+
redirect: RequestRedirect | undefined;
|
|
54
|
+
signal: AbortSignal | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A `fetch` that answers every call the same way and records what it was
|
|
59
|
+
* handed. `answer` may throw, to stand in for a socket error.
|
|
60
|
+
*/
|
|
61
|
+
function wire(answer: () => Response | Promise<Response>): { fetch: typeof globalThis.fetch; seen: Seen[] } {
|
|
62
|
+
const seen: Seen[] = [];
|
|
63
|
+
const fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
64
|
+
seen.push({
|
|
65
|
+
url: String(url),
|
|
66
|
+
method: init?.method,
|
|
67
|
+
headers: new Headers(init?.headers),
|
|
68
|
+
body: String(init?.body),
|
|
69
|
+
redirect: init?.redirect,
|
|
70
|
+
signal: init?.signal ?? undefined,
|
|
71
|
+
});
|
|
72
|
+
return answer();
|
|
73
|
+
}) as typeof globalThis.fetch;
|
|
74
|
+
return { fetch, seen };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const answering = (status: number, text: string, headers: Record<string, string> = {}) =>
|
|
78
|
+
wire(() => new Response(text, { status, headers }));
|
|
79
|
+
|
|
80
|
+
/** The refusal from an outcome, or a thrown error naming what came back instead. */
|
|
81
|
+
function refusalOf(outcome: BehaviourTransportOutcome): BehaviourRefusalReport {
|
|
82
|
+
if (outcome.ok) throw new Error(`expected a refusal, got an answer: ${outcome.body}`);
|
|
83
|
+
return outcome.refusal;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* -------------------------------------------------------------------------- */
|
|
87
|
+
/* The token chain */
|
|
88
|
+
/* -------------------------------------------------------------------------- */
|
|
89
|
+
|
|
90
|
+
describe("the token chain (#2359)", () => {
|
|
91
|
+
test("is parallel to the address chain and shares no variable with it", () => {
|
|
92
|
+
expect(behaviourTokenVariables("acme")).toEqual([
|
|
93
|
+
"CHANT_BEHAVIOUR_TOKEN_ACME",
|
|
94
|
+
"CHANT_BEHAVIOUR_TOKEN",
|
|
95
|
+
"BEHAVIOUR_TOKEN",
|
|
96
|
+
]);
|
|
97
|
+
// An address is printed in refusals and a token never is, so a variable
|
|
98
|
+
// in one chain must not be in the other.
|
|
99
|
+
const addresses = ["CHANT_BEHAVIOUR_ENGINE_ACME", "CHANT_BEHAVIOUR_ENGINE", "BEHAVIOUR_ENGINE"];
|
|
100
|
+
for (const v of behaviourTokenVariables("acme")) expect(addresses).not.toContain(v);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("reads most specific first, and names the variable that won", () => {
|
|
104
|
+
expect(
|
|
105
|
+
behaviourTokenFrom("acme", {
|
|
106
|
+
CHANT_BEHAVIOUR_TOKEN_ACME: "scoped",
|
|
107
|
+
CHANT_BEHAVIOUR_TOKEN: "chant-wide",
|
|
108
|
+
BEHAVIOUR_TOKEN: "bare",
|
|
109
|
+
}),
|
|
110
|
+
).toEqual({ value: "scoped", source: "CHANT_BEHAVIOUR_TOKEN_ACME" });
|
|
111
|
+
expect(behaviourTokenFrom("acme", { CHANT_BEHAVIOUR_TOKEN: "chant-wide", BEHAVIOUR_TOKEN: "bare" })).toEqual({
|
|
112
|
+
value: "chant-wide",
|
|
113
|
+
source: "CHANT_BEHAVIOUR_TOKEN",
|
|
114
|
+
});
|
|
115
|
+
expect(behaviourTokenFrom("acme", { BEHAVIOUR_TOKEN: "bare" })).toEqual({ value: "bare", source: "BEHAVIOUR_TOKEN" });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("skips a blank variable rather than sending an empty bearer", () => {
|
|
119
|
+
expect(behaviourTokenFrom("acme", { CHANT_BEHAVIOUR_TOKEN_ACME: " ", BEHAVIOUR_TOKEN: "bare" })).toEqual({
|
|
120
|
+
value: "bare",
|
|
121
|
+
source: "BEHAVIOUR_TOKEN",
|
|
122
|
+
});
|
|
123
|
+
expect(behaviourTokenFrom("acme", {})).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("the address chain does not resolve a token, and the token chain does not resolve an address", () => {
|
|
127
|
+
const env = { CHANT_BEHAVIOUR_ENGINE: "https://engine.example", CHANT_BEHAVIOUR_TOKEN: TOKEN };
|
|
128
|
+
expect(behaviourEngineFrom("acme", env)?.value).toBe("https://engine.example");
|
|
129
|
+
expect(behaviourTokenFrom("acme", env)?.value).toBe(TOKEN);
|
|
130
|
+
expect(behaviourEngineFrom("acme", { CHANT_BEHAVIOUR_TOKEN: TOKEN })).toBeUndefined();
|
|
131
|
+
expect(behaviourTokenFrom("acme", { CHANT_BEHAVIOUR_ENGINE: "https://engine.example" })).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/* -------------------------------------------------------------------------- */
|
|
136
|
+
/* What is sent */
|
|
137
|
+
/* -------------------------------------------------------------------------- */
|
|
138
|
+
|
|
139
|
+
describe("what the adapter puts on the wire", () => {
|
|
140
|
+
test("POSTs the body byte for byte, as JSON, with the bearer token in the header", async () => {
|
|
141
|
+
const { fetch, seen } = answering(200, '{"engine":"e"}');
|
|
142
|
+
const outcome = await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY);
|
|
143
|
+
expect(outcome).toEqual({ ok: true, body: '{"engine":"e"}' });
|
|
144
|
+
expect(seen).toHaveLength(1);
|
|
145
|
+
const [call] = seen;
|
|
146
|
+
expect(call.url).toBe("https://engine.example/v1/predict");
|
|
147
|
+
expect(call.method).toBe("POST");
|
|
148
|
+
expect(call.body).toBe(BODY);
|
|
149
|
+
expect(call.headers.get("content-type")).toBe("application/json");
|
|
150
|
+
expect(call.headers.get("accept")).toBe("application/json");
|
|
151
|
+
expect(call.headers.get("authorization")).toBe(`Bearer ${TOKEN}`);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("the token is in the header and not in the body", async () => {
|
|
155
|
+
const { fetch, seen } = answering(200, "{}");
|
|
156
|
+
await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY);
|
|
157
|
+
expect(seen[0].body).not.toContain(TOKEN);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("does not follow a redirect, which would carry the header to another host", async () => {
|
|
161
|
+
const { fetch, seen } = answering(200, "{}");
|
|
162
|
+
await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY);
|
|
163
|
+
expect(seen[0].redirect).toBe("error");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("sends with a deadline", async () => {
|
|
167
|
+
const { fetch, seen } = answering(200, "{}");
|
|
168
|
+
await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY);
|
|
169
|
+
expect(seen[0].signal).toBeInstanceOf(AbortSignal);
|
|
170
|
+
expect(HTTP_BEHAVIOUR_TIMEOUT_MS).toBe(30_000);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("uses the most specific token variable when two are set", async () => {
|
|
174
|
+
const { fetch, seen } = answering(200, "{}");
|
|
175
|
+
await httpBehaviourTransport(
|
|
176
|
+
"acme",
|
|
177
|
+
ENDPOINT,
|
|
178
|
+
{ CHANT_BEHAVIOUR_TOKEN_ACME: "scoped-token-value", CHANT_BEHAVIOUR_TOKEN: TOKEN },
|
|
179
|
+
{ fetch },
|
|
180
|
+
).send(BODY);
|
|
181
|
+
expect(seen[0].headers.get("authorization")).toBe("Bearer scoped-token-value");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("dials http and https, and nothing else", () => {
|
|
185
|
+
expect(isHttpBehaviourAddress("https://engine.example/predict")).toBe(true);
|
|
186
|
+
expect(isHttpBehaviourAddress("http://localhost:8080/predict")).toBe(true);
|
|
187
|
+
expect(isHttpBehaviourAddress(" HTTPS://ENGINE.EXAMPLE ")).toBe(true);
|
|
188
|
+
expect(isHttpBehaviourAddress("grpc://engine.internal:9000")).toBe(false);
|
|
189
|
+
expect(isHttpBehaviourAddress("/var/run/engine.sock")).toBe(false);
|
|
190
|
+
expect(isHttpBehaviourAddress("acme-engine --model tiny")).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/* -------------------------------------------------------------------------- */
|
|
195
|
+
/* No token, nothing sent */
|
|
196
|
+
/* -------------------------------------------------------------------------- */
|
|
197
|
+
|
|
198
|
+
describe("no token configured", () => {
|
|
199
|
+
test("refuses before sending, naming the three variables, as `no-engine`", async () => {
|
|
200
|
+
const { fetch, seen } = answering(200, "{}");
|
|
201
|
+
const outcome = await httpBehaviourTransport("acme", ENDPOINT, {}, { fetch }).send(BODY);
|
|
202
|
+
const { refusal } = refusalOf(outcome);
|
|
203
|
+
expect(seen, "nothing may be sent without a token").toEqual([]);
|
|
204
|
+
expect(refusal.cause).toBe("no-engine");
|
|
205
|
+
expect(refusal.reason).toContain("CHANT_BEHAVIOUR_TOKEN_ACME");
|
|
206
|
+
expect(refusal.reason).toContain("CHANT_BEHAVIOUR_TOKEN");
|
|
207
|
+
expect(refusal.reason).toContain("BEHAVIOUR_TOKEN");
|
|
208
|
+
expect(refusal.reason).toContain("engine.example");
|
|
209
|
+
expect(refusal.reason).toContain("CHANT_BEHAVIOUR_ENGINE");
|
|
210
|
+
expect(refusal.remedy).toBe("Set CHANT_BEHAVIOUR_TOKEN to the bearer token the engine at https://engine.example/v1/predict issued.");
|
|
211
|
+
// The address chain answered; the refusal is about the token chain, in
|
|
212
|
+
// which nothing did, so there is no `source`.
|
|
213
|
+
expect(refusal.source).toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("the address is redacted in the no-token refusal too", async () => {
|
|
217
|
+
const { fetch } = answering(200, "{}");
|
|
218
|
+
const leaky = { value: "https://svc:s3cr3t@engine.example/predict?key=abc123", source: "BEHAVIOUR_ENGINE" };
|
|
219
|
+
const { refusal } = refusalOf(await httpBehaviourTransport("acme", leaky, {}, { fetch }).send(BODY));
|
|
220
|
+
for (const text of [refusal.reason, refusal.remedy]) {
|
|
221
|
+
expect(text).not.toContain("s3cr3t");
|
|
222
|
+
expect(text).not.toContain("abc123");
|
|
223
|
+
expect(text).toContain("engine.example");
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/* -------------------------------------------------------------------------- */
|
|
229
|
+
/* The wire mapping */
|
|
230
|
+
/* -------------------------------------------------------------------------- */
|
|
231
|
+
|
|
232
|
+
describe("what the wire says, and what it becomes", () => {
|
|
233
|
+
test("402 is `engine-out-of-credit`, naming the token variable and not the network", async () => {
|
|
234
|
+
const { fetch } = answering(402, '{"error":"balance 0.00 USD"}');
|
|
235
|
+
const { refusal } = refusalOf(await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY));
|
|
236
|
+
expect(refusal.cause).toBe("engine-out-of-credit");
|
|
237
|
+
expect(refusal.source).toBe("CHANT_BEHAVIOUR_ENGINE");
|
|
238
|
+
// The condition, and both variables: the address that answered and the
|
|
239
|
+
// token whose account is empty.
|
|
240
|
+
expect(refusal.reason).toContain("out of credit");
|
|
241
|
+
expect(refusal.reason).toContain("HTTP 402");
|
|
242
|
+
expect(refusal.reason).toContain("balance 0.00 USD");
|
|
243
|
+
expect(refusal.reason).toContain("CHANT_BEHAVIOUR_TOKEN");
|
|
244
|
+
expect(refusal.reason).toContain("CHANT_BEHAVIOUR_ENGINE");
|
|
245
|
+
expect(refusal.reason).not.toMatch(/unreachable|did not answer/i);
|
|
246
|
+
expect(refusal.remedy).not.toMatch(/reachable/i);
|
|
247
|
+
expect(refusal.remedy).toMatch(/credit|fund/i);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("429 is `engine-over-quota`, echoing retry-after, and not out of credit", async () => {
|
|
251
|
+
const { fetch } = answering(429, "too many requests", { "retry-after": "900" });
|
|
252
|
+
const { refusal } = refusalOf(await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY));
|
|
253
|
+
expect(refusal.cause).toBe("engine-over-quota");
|
|
254
|
+
expect(refusal.reason).toContain("HTTP 429");
|
|
255
|
+
expect(refusal.reason).toContain("retry after 900");
|
|
256
|
+
expect(refusal.reason).toContain("CHANT_BEHAVIOUR_TOKEN");
|
|
257
|
+
expect(refusal.remedy).toMatch(/quota|limit|window|wait/i);
|
|
258
|
+
expect(refusal.remedy).not.toMatch(/credit|fund/i);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("a 5xx is `engine-unreachable`, naming the status", async () => {
|
|
262
|
+
const { fetch } = answering(503, "<html>Service Unavailable</html>");
|
|
263
|
+
const { refusal } = refusalOf(await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY));
|
|
264
|
+
expect(refusal.cause).toBe("engine-unreachable");
|
|
265
|
+
expect(refusal.source).toBe("CHANT_BEHAVIOUR_ENGINE");
|
|
266
|
+
expect(refusal.reason).toContain("HTTP 503");
|
|
267
|
+
expect(refusal.remedy).toMatch(/reachable|repoint/i);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("a connection refused is `engine-unreachable`, naming the socket error", async () => {
|
|
271
|
+
const { fetch } = wire(() => {
|
|
272
|
+
throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED", message: "connect ECONNREFUSED 127.0.0.1:443" } });
|
|
273
|
+
});
|
|
274
|
+
const { refusal } = refusalOf(await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY));
|
|
275
|
+
expect(refusal.cause).toBe("engine-unreachable");
|
|
276
|
+
expect(refusal.reason).toContain("ECONNREFUSED");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("a timeout is `engine-unreachable`, naming the deadline", async () => {
|
|
280
|
+
// The fake waits on the signal the adapter hands it, and rejects with the
|
|
281
|
+
// signal's own reason — which is what undici does when the deadline
|
|
282
|
+
// passes — so the real `AbortSignal.timeout` path is the one under test.
|
|
283
|
+
const fetch = (async (_url: string | URL | Request, init?: RequestInit) =>
|
|
284
|
+
new Promise<Response>((_resolve, reject) => {
|
|
285
|
+
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
|
|
286
|
+
})) as typeof globalThis.fetch;
|
|
287
|
+
const { refusal } = refusalOf(
|
|
288
|
+
await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch, timeoutMs: 5 }).send(BODY),
|
|
289
|
+
);
|
|
290
|
+
expect(refusal.cause).toBe("engine-unreachable");
|
|
291
|
+
expect(refusal.reason).toContain("no answer within 5 ms");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("a redirect refused by fetch is `engine-unreachable`", async () => {
|
|
295
|
+
const { fetch } = wire(() => {
|
|
296
|
+
throw Object.assign(new TypeError("fetch failed"), { cause: { code: undefined, message: "unexpected redirect" } });
|
|
297
|
+
});
|
|
298
|
+
const { refusal } = refusalOf(await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY));
|
|
299
|
+
expect(refusal.cause).toBe("engine-unreachable");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("401 and 403 are `no-engine` naming the token variable that was rejected", async () => {
|
|
303
|
+
for (const status of [401, 403]) {
|
|
304
|
+
const { fetch } = answering(status, "invalid token");
|
|
305
|
+
const { refusal } = refusalOf(await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY));
|
|
306
|
+
expect(refusal.cause, `HTTP ${status}`).toBe("no-engine");
|
|
307
|
+
expect(refusal.source).toBe("CHANT_BEHAVIOUR_TOKEN");
|
|
308
|
+
expect(refusal.reason).toContain(`HTTP ${status}`);
|
|
309
|
+
expect(refusal.reason).toContain("rejected the token CHANT_BEHAVIOUR_TOKEN holds");
|
|
310
|
+
expect(refusal.remedy).toBe("Set CHANT_BEHAVIOUR_TOKEN to a bearer token the engine at https://engine.example/v1/predict accepts.");
|
|
311
|
+
expect(refusal.remedy).not.toMatch(/reachable|credit/i);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("any other non-2xx is `engine-unreachable`, with the status in the detail", () => {
|
|
316
|
+
const token = { value: TOKEN, source: "CHANT_BEHAVIOUR_TOKEN" };
|
|
317
|
+
for (const status of [301, 400, 404, 418, 422, 500, 502]) {
|
|
318
|
+
const refusal = httpStatusRefusal("acme", ENDPOINT, token, status, "")!;
|
|
319
|
+
expect(refusal.refusal.cause, `HTTP ${status}`).toBe("engine-unreachable");
|
|
320
|
+
expect(refusal.refusal.reason).toContain(`HTTP ${status}`);
|
|
321
|
+
}
|
|
322
|
+
for (const status of [200, 201, 204]) {
|
|
323
|
+
expect(httpStatusRefusal("acme", ENDPOINT, token, status, "")).toBeUndefined();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("the four kinds are four distinct causes from one wire", async () => {
|
|
328
|
+
const causes = await Promise.all(
|
|
329
|
+
[
|
|
330
|
+
httpBehaviourTransport("acme", ENDPOINT, {}, answering(200, "{}")).send(BODY),
|
|
331
|
+
httpBehaviourTransport("acme", ENDPOINT, ENV, answering(503, "down")).send(BODY),
|
|
332
|
+
httpBehaviourTransport("acme", ENDPOINT, ENV, answering(402, "broke")).send(BODY),
|
|
333
|
+
httpBehaviourTransport("acme", ENDPOINT, ENV, answering(429, "throttled")).send(BODY),
|
|
334
|
+
].map(async (p) => refusalOf(await p).refusal.cause),
|
|
335
|
+
);
|
|
336
|
+
expect(causes).toEqual(["no-engine", "engine-unreachable", "engine-out-of-credit", "engine-over-quota"]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("a 2xx answer is handed back as text, unparsed — the lexicon owns the wire version", async () => {
|
|
340
|
+
const { fetch } = answering(200, "not json at all");
|
|
341
|
+
const outcome = await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY);
|
|
342
|
+
expect(outcome).toEqual({ ok: true, body: "not json at all" });
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/* -------------------------------------------------------------------------- */
|
|
347
|
+
/* The token is never printed */
|
|
348
|
+
/* -------------------------------------------------------------------------- */
|
|
349
|
+
|
|
350
|
+
describe("the token appears in no refusal of any kind (#2358 posts these publicly)", () => {
|
|
351
|
+
/**
|
|
352
|
+
* Every way the wire can refuse, each with the engine echoing the token it
|
|
353
|
+
* was sent back into its error body as a bare value — which is what a JSON
|
|
354
|
+
* error body carries — so the one path a token could take into a message is
|
|
355
|
+
* exercised on every kind.
|
|
356
|
+
*
|
|
357
|
+
* Bare, not `Bearer …`: `scrubEngineDetail` already blanks anything in the
|
|
358
|
+
* `Bearer <16+ chars>` shape through `CREDENTIAL_SHAPES`, so an echo in that
|
|
359
|
+
* shape passes with {@link concealing} deleted (it did: 47/47 green under
|
|
360
|
+
* that tamper). The value on its own is the case only this adapter catches.
|
|
361
|
+
*/
|
|
362
|
+
const echo = `{"error":"token ${TOKEN} is not valid for this account","request_id":"r-1"}`;
|
|
363
|
+
const kinds: Array<[string, () => { fetch: typeof globalThis.fetch }]> = [
|
|
364
|
+
["no-engine (rejected token, 401)", () => answering(401, echo)],
|
|
365
|
+
["no-engine (rejected token, 403)", () => answering(403, echo)],
|
|
366
|
+
["engine-unreachable (503)", () => answering(503, echo)],
|
|
367
|
+
["engine-unreachable (thrown)", () => wire(() => { throw new Error(`socket hung up after sending ${echo}`); })],
|
|
368
|
+
["engine-out-of-credit (402)", () => answering(402, echo)],
|
|
369
|
+
["engine-over-quota (429)", () => answering(429, echo, { "retry-after": "60" })],
|
|
370
|
+
];
|
|
371
|
+
|
|
372
|
+
for (const [kind, make] of kinds) {
|
|
373
|
+
test(`${kind}: neither the reason nor the remedy carries the token`, async () => {
|
|
374
|
+
const { fetch } = make();
|
|
375
|
+
const outcome = await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY);
|
|
376
|
+
const { refusal } = refusalOf(outcome);
|
|
377
|
+
expect(isBehaviourRefusalReport(outcome.ok ? undefined! : outcome.refusal)).toBe(true);
|
|
378
|
+
for (const [field, text] of Object.entries(refusal)) {
|
|
379
|
+
expect(String(text), `${kind}: the token leaked through refusal.${field}`).not.toContain(TOKEN);
|
|
380
|
+
}
|
|
381
|
+
// The whole object, serialized: no field anyone adds later can carry it
|
|
382
|
+
// past this assertion.
|
|
383
|
+
expect(JSON.stringify(outcome)).not.toContain(TOKEN);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
test("the no-token refusal has no token to leak, and names the variables only", async () => {
|
|
388
|
+
const { fetch } = answering(401, echo);
|
|
389
|
+
const outcome = await httpBehaviourTransport("acme", ENDPOINT, {}, { fetch }).send(BODY);
|
|
390
|
+
expect(JSON.stringify(outcome)).not.toContain(TOKEN);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("a token the engine echoes into a 2xx body is concealed there too", async () => {
|
|
394
|
+
const { fetch } = answering(200, `{"echo":"${TOKEN}"}`);
|
|
395
|
+
const outcome = await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY);
|
|
396
|
+
expect(outcome.ok).toBe(true);
|
|
397
|
+
if (!outcome.ok) return;
|
|
398
|
+
expect(outcome.body).not.toContain(TOKEN);
|
|
399
|
+
expect(outcome.body).toContain("[redacted]");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("concealing replaces every occurrence, and leaves a too-short token's text alone", () => {
|
|
403
|
+
const token = { value: TOKEN, source: "CHANT_BEHAVIOUR_TOKEN" };
|
|
404
|
+
expect(concealing(token, `a ${TOKEN} b ${TOKEN} c`)).toBe("a [redacted] b [redacted] c");
|
|
405
|
+
expect(concealing(token, "nothing here")).toBe("nothing here");
|
|
406
|
+
// A one-letter "token" would blank every `a` in the detail. Not concealed,
|
|
407
|
+
// and not a secret anybody has.
|
|
408
|
+
expect(concealing({ value: "a", source: "BEHAVIOUR_TOKEN" }, "a detail about a thing")).toBe("a detail about a thing");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("a token with regex metacharacters is matched as itself", () => {
|
|
412
|
+
const token = { value: "sk.live+9f8e/7d6c=", source: "BEHAVIOUR_TOKEN" };
|
|
413
|
+
expect(concealing(token, "sent sk.live+9f8e/7d6c= twice: sk.live+9f8e/7d6c=")).toBe("sent [redacted] twice: [redacted]");
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
/* -------------------------------------------------------------------------- */
|
|
418
|
+
/* What a consumer can assume about the two strings it prints */
|
|
419
|
+
/* -------------------------------------------------------------------------- */
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* behold renders `refusal.reason` and `refusal.remedy` verbatim and bounds
|
|
423
|
+
* neither (`validateBehaviourMeta` requires both and reads nothing else), and
|
|
424
|
+
* a refusal from one estate member blanks the overlay for the whole estate —
|
|
425
|
+
* so those two strings are the only text on screen explaining why. Two things
|
|
426
|
+
* follow, and both are this adapter's to hold rather than the consumer's.
|
|
427
|
+
*/
|
|
428
|
+
describe("the two strings a consumer prints", () => {
|
|
429
|
+
test("an engine that writes a novel gets a bounded reason, not the novel", async () => {
|
|
430
|
+
const novel = `balance 0.00 USD. ${"why this happened, at length. ".repeat(500)}`;
|
|
431
|
+
const { fetch } = answering(402, novel);
|
|
432
|
+
const { refusal } = refusalOf(await httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch }).send(BODY));
|
|
433
|
+
// `firstLine` bounds what is quoted and `scrubEngineDetail` bounds it
|
|
434
|
+
// again; a reason that ran to the engine's whole page would be the only
|
|
435
|
+
// thing on a consumer's screen.
|
|
436
|
+
expect(refusal.reason.length).toBeLessThan(1000);
|
|
437
|
+
expect(refusal.reason).toContain("balance 0.00 USD");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("every refusal this adapter can build says something and says what to do", async () => {
|
|
441
|
+
const kinds: Array<[string, () => Promise<BehaviourTransportOutcome>]> = [
|
|
442
|
+
["no token", () => httpBehaviourTransport("acme", ENDPOINT, {}, { fetch: answering(200, "{}").fetch }).send(BODY)],
|
|
443
|
+
["401", () => httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch: answering(401, "nope").fetch }).send(BODY)],
|
|
444
|
+
["402", () => httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch: answering(402, "empty").fetch }).send(BODY)],
|
|
445
|
+
["429", () => httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch: answering(429, "slow down").fetch }).send(BODY)],
|
|
446
|
+
["503", () => httpBehaviourTransport("acme", ENDPOINT, ENV, { fetch: answering(503, "").fetch }).send(BODY)],
|
|
447
|
+
];
|
|
448
|
+
for (const [kind, run] of kinds) {
|
|
449
|
+
const { refusal } = refusalOf(await run());
|
|
450
|
+
expect(refusal.reason.trim(), `${kind}: an empty reason`).not.toHaveLength(0);
|
|
451
|
+
expect(refusal.remedy.trim(), `${kind}: an empty remedy`).not.toHaveLength(0);
|
|
452
|
+
// A remedy that names no variable is a dead end for whoever reads it.
|
|
453
|
+
expect(refusal.remedy, `${kind}: the remedy names no variable`).toMatch(/\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
});
|