@elench/testkit 0.1.81 → 0.1.83

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.
Files changed (55) hide show
  1. package/README.md +64 -27
  2. package/lib/cli/agents/index.mjs +64 -0
  3. package/lib/cli/agents/investigate.mjs +75 -0
  4. package/lib/cli/agents/investigation-context.mjs +102 -0
  5. package/lib/cli/agents/investigation-context.test.mjs +144 -0
  6. package/lib/cli/agents/prompt-builder.mjs +25 -0
  7. package/lib/cli/agents/providers/claude.mjs +74 -0
  8. package/lib/cli/agents/providers/claude.test.mjs +95 -0
  9. package/lib/cli/agents/providers/codex.mjs +83 -0
  10. package/lib/cli/agents/providers/codex.test.mjs +93 -0
  11. package/lib/cli/agents/providers/shared.mjs +134 -0
  12. package/lib/cli/command-helpers.mjs +53 -25
  13. package/lib/cli/command-helpers.test.mjs +122 -0
  14. package/lib/cli/commands/investigate.mjs +87 -0
  15. package/lib/cli/commands/investigate.test.mjs +83 -0
  16. package/lib/cli/entrypoint.mjs +3 -0
  17. package/lib/cli/presentation/colors.mjs +12 -0
  18. package/lib/cli/presentation/events-reporter.mjs +135 -0
  19. package/lib/cli/presentation/events-reporter.test.mjs +73 -0
  20. package/lib/cli/presentation/summary-box.mjs +11 -11
  21. package/lib/cli/presentation/summary-box.test.mjs +17 -0
  22. package/lib/cli/presentation/tree-reporter.mjs +159 -0
  23. package/lib/cli/presentation/tree-reporter.test.mjs +166 -0
  24. package/lib/cli/tui/run-app.mjs +1 -0
  25. package/lib/cli/tui/run-session-app.mjs +370 -0
  26. package/lib/cli/tui/run-session-app.test.mjs +50 -0
  27. package/lib/cli/tui/run-session-state.mjs +481 -0
  28. package/lib/cli/tui/run-tree-state.mjs +1 -0
  29. package/lib/cli/tui/run-tree-state.test.mjs +324 -0
  30. package/lib/config-api/auth-fixtures.mjs +767 -0
  31. package/lib/config-api/index.d.ts +92 -108
  32. package/lib/config-api/index.mjs +22 -12
  33. package/lib/config-api/index.test.mjs +103 -210
  34. package/lib/discovery/index.mjs +1 -1
  35. package/lib/index.d.ts +34 -10
  36. package/lib/runner/orchestrator.mjs +1 -0
  37. package/lib/runtime/index.d.ts +177 -27
  38. package/lib/runtime/index.mjs +68 -3
  39. package/lib/runtime-src/k6/http-assertions.js +31 -1
  40. package/lib/runtime-src/k6/http-checks.js +120 -0
  41. package/lib/runtime-src/k6/http-checks.test.mjs +120 -0
  42. package/lib/runtime-src/k6/http-suite-runtime.js +151 -0
  43. package/lib/runtime-src/k6/http.js +285 -56
  44. package/lib/runtime-src/k6/http.test.mjs +205 -0
  45. package/lib/runtime-src/k6/scenario-suite.js +13 -110
  46. package/lib/runtime-src/k6/suite.js +13 -107
  47. package/lib/runtime-src/shared/error-body.mjs +42 -0
  48. package/lib/runtime-src/shared/http-parsing.mjs +68 -0
  49. package/lib/runtime-src/shared/http-parsing.test.mjs +69 -0
  50. package/node_modules/@elench/next-analysis/package.json +1 -1
  51. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  52. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  53. package/node_modules/@elench/ts-analysis/package.json +1 -1
  54. package/package.json +5 -5
  55. package/lib/config-api/profiles.mjs +0 -640
@@ -23,6 +23,9 @@ export interface RuntimeResponse {
23
23
 
24
24
  export interface RuntimeHttpTrace {
25
25
  id: string;
26
+ actorName: string | null;
27
+ organizationKey?: string | null;
28
+ organizationName?: string | null;
26
29
  requestId: string;
27
30
  startedAt: string;
28
31
  finishedAt?: string;
@@ -93,6 +96,27 @@ export interface RuntimeHttpClient {
93
96
  put(url: string, body?: unknown, params?: HttpRequestParams): RuntimeResponse;
94
97
  }
95
98
 
99
+ export interface RuntimeActorRecord<TSession = Record<string, unknown>> {
100
+ actorIndex: number;
101
+ actorName: string;
102
+ email?: string;
103
+ name?: string;
104
+ organizationKey?: string;
105
+ organizationName?: string;
106
+ session: TSession | null;
107
+ }
108
+
109
+ export interface AuthSessionBundle<TSession = Record<string, unknown>> {
110
+ actors: Record<string, RuntimeActorRecord<TSession> & TSession>;
111
+ primaryActor: string | null;
112
+ }
113
+
114
+ export interface HttpHeaderBuildContext<TSession = Record<string, unknown>> {
115
+ actor: (RuntimeActorRecord<TSession> & TSession) | null;
116
+ actorName: string | null;
117
+ sessionBundle: AuthSessionBundle<TSession> | null;
118
+ }
119
+
96
120
  export interface Metric {
97
121
  add(value: number): void;
98
122
  }
@@ -109,53 +133,105 @@ export declare class Trend implements Metric {
109
133
 
110
134
  export interface HttpClientConfig<TSetup = unknown> {
111
135
  baseUrl: string;
136
+ defaultActor?: string | null;
112
137
  defaultHeaders?: RuntimeHeaders;
113
- getHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void;
114
- getRawHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void;
138
+ getHeaders?: (context: HttpHeaderBuildContext<TSetup>) => RuntimeHeaders | void;
139
+ getRawHeaders?: (context: HttpHeaderBuildContext<TSetup>) => RuntimeHeaders | void;
115
140
  routeHeaders?: RuntimeHeaders;
141
+ sessionBundle?: AuthSessionBundle<TSetup> | null;
116
142
  }
117
143
 
118
- export interface HttpClient<TSetup = unknown> {
119
- delete(path: string, setupData?: TSetup | null, extraHeaders?: RuntimeHeaders): RuntimeResponse;
120
- get(path: string, setupData?: TSetup | null, extraHeaders?: RuntimeHeaders): RuntimeResponse;
121
- getWithHeaders(
122
- path: string,
123
- setupData?: TSetup | null,
124
- extraHeaders?: RuntimeHeaders
125
- ): RuntimeResponse;
126
- patch(
144
+ export interface MultipartFileInput {
145
+ contentType?: string;
146
+ data: unknown;
147
+ field: string;
148
+ filename?: string;
149
+ }
150
+
151
+ export interface MultipartPayload {
152
+ fields?: Record<string, unknown>;
153
+ files?: MultipartFileInput[];
154
+ }
155
+
156
+ export interface MultipartRequestClient {
157
+ patch(path: string, payload: MultipartPayload, extraHeaders?: RuntimeHeaders): RuntimeResponse;
158
+ post(path: string, payload: MultipartPayload, extraHeaders?: RuntimeHeaders): RuntimeResponse;
159
+ put(path: string, payload: MultipartPayload, extraHeaders?: RuntimeHeaders): RuntimeResponse;
160
+ }
161
+
162
+ export interface RawRequestClient {
163
+ (
164
+ method: RuntimeMethod,
127
165
  path: string,
128
- setupData?: TSetup | null,
129
166
  body?: unknown,
130
167
  extraHeaders?: RuntimeHeaders
131
168
  ): RuntimeResponse;
132
- post(
169
+ as(actorName: string): RawRequestClient;
170
+ delete(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
171
+ get(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
172
+ headers(extraHeaders?: RuntimeHeaders): RuntimeHeaders;
173
+ multipart: MultipartRequestClient;
174
+ patch(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
175
+ post(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
176
+ put(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
177
+ request(
178
+ method: RuntimeMethod,
133
179
  path: string,
134
- setupData?: TSetup | null,
135
180
  body?: unknown,
136
181
  extraHeaders?: RuntimeHeaders
137
182
  ): RuntimeResponse;
138
- put(
183
+ }
184
+
185
+ export interface ActorRequestClient {
186
+ headers(extraHeaders?: RuntimeHeaders): RuntimeHeaders;
187
+ multipart: MultipartRequestClient;
188
+ raw(
189
+ method: RuntimeMethod,
139
190
  path: string,
140
- setupData?: TSetup | null,
141
191
  body?: unknown,
142
192
  extraHeaders?: RuntimeHeaders
143
193
  ): RuntimeResponse;
144
- raw(
194
+ rawDelete(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
195
+ rawGet(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
196
+ rawHeaders(extraHeaders?: RuntimeHeaders): RuntimeHeaders;
197
+ rawMultipart: MultipartRequestClient;
198
+ rawPatch(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
199
+ rawPost(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
200
+ rawPut(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
201
+ rawReq: RawRequestClient;
202
+ delete(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
203
+ get(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
204
+ patch(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
205
+ post(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
206
+ put(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
207
+ request(
145
208
  method: RuntimeMethod,
146
209
  path: string,
147
210
  body?: unknown,
148
211
  extraHeaders?: RuntimeHeaders
149
212
  ): RuntimeResponse;
213
+ }
214
+
215
+ export interface HttpClient<TSetup = unknown> {
216
+ as(actorName: string): ActorRequestClient;
217
+ headers(extraHeaders?: RuntimeHeaders): RuntimeHeaders;
218
+ multipart: MultipartRequestClient;
219
+ delete(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
220
+ get(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
221
+ patch(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
222
+ post(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
223
+ put(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
150
224
  rawDelete(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
151
225
  rawGet(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
226
+ rawHeaders(actorName?: string | null, extraHeaders?: RuntimeHeaders): RuntimeHeaders;
227
+ rawMultipart: MultipartRequestClient;
152
228
  rawPatch(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
153
229
  rawPost(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
154
230
  rawPut(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
231
+ raw: RawRequestClient;
155
232
  request(
156
233
  method: RuntimeMethod,
157
234
  path: string,
158
- setupData?: TSetup | null,
159
235
  body?: unknown,
160
236
  extraHeaders?: RuntimeHeaders
161
237
  ): RuntimeResponse;
@@ -226,19 +302,16 @@ export declare function createHttpClient<TSetup = unknown>(
226
302
  ): HttpClient<TSetup>;
227
303
  export declare function makeReq<TSetup = unknown>(
228
304
  baseUrl: string,
305
+ sessionBundle?: AuthSessionBundle<TSetup> | null,
229
306
  routeHeaders?: RuntimeHeaders,
230
- getHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void
231
- ): HttpClient<TSetup>["request"];
307
+ getHeaders?: (context: HttpHeaderBuildContext<TSetup>) => RuntimeHeaders | void,
308
+ defaultActor?: string | null
309
+ ): HttpClient<TSetup>;
232
310
  export declare function makeRawReq(
233
311
  baseUrl: string,
234
312
  routeHeaders?: RuntimeHeaders,
235
- getRawHeaders?: (setupData?: never) => RuntimeHeaders | void
236
- ): HttpClient["raw"];
237
- export declare function makeGetWithHeaders<TSetup = unknown>(
238
- baseUrl: string,
239
- routeHeaders?: RuntimeHeaders,
240
- getHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void
241
- ): HttpClient<TSetup>["getWithHeaders"];
313
+ getRawHeaders?: (context: HttpHeaderBuildContext<never>) => RuntimeHeaders | void
314
+ ): RawRequestClient;
242
315
 
243
316
  export declare function expectStatus(
244
317
  response: RuntimeResponse,
@@ -266,6 +339,83 @@ export declare function expectJsonPath(
266
339
  predicate: (value: unknown) => boolean,
267
340
  label?: string | null
268
341
  ): boolean;
342
+ export declare function expectErrorShape(
343
+ response: RuntimeResponse,
344
+ label?: string | null
345
+ ): boolean;
346
+ export declare function expectErrorMessage(
347
+ response: RuntimeResponse,
348
+ label?: string | null
349
+ ): boolean;
350
+ export declare function expectResponse(
351
+ response: RuntimeResponse,
352
+ predicate: (response: RuntimeResponse) => boolean,
353
+ label: string
354
+ ): boolean;
355
+ export declare function expectValue<T>(
356
+ value: T,
357
+ predicate: (value: T) => boolean,
358
+ label: string
359
+ ): boolean;
360
+ export declare function expectCondition(
361
+ predicate: () => boolean,
362
+ label: string
363
+ ): boolean;
364
+
365
+ export declare function runAuthGateChecks(
366
+ rawReq: RawRequestClient,
367
+ scope: string,
368
+ descriptors: {
369
+ delete?: Array<string>;
370
+ get?: Array<string>;
371
+ patch?: Array<string | [string, unknown]>;
372
+ post?: Array<string | [string, unknown]>;
373
+ put?: Array<string | [string, unknown]>;
374
+ validateGetErrorShape?: boolean;
375
+ }
376
+ ): void;
377
+ export declare function runPaginationChecks(
378
+ req: Pick<HttpClient, "get">,
379
+ endpoint: string,
380
+ options?: { auditLogsExtra?: boolean }
381
+ ): void;
382
+
383
+ export interface RuntimeExpectNamespace {
384
+ condition: typeof expectCondition;
385
+ error: {
386
+ message: typeof expectErrorMessage;
387
+ shape: typeof expectErrorShape;
388
+ };
389
+ json: typeof expectJson;
390
+ jsonPath: typeof expectJsonPath;
391
+ notStatus: typeof expectNotStatus;
392
+ response: typeof expectResponse;
393
+ status: typeof expectStatus;
394
+ statusOneOf: typeof expectStatusOneOf;
395
+ value: typeof expectValue;
396
+ }
397
+
398
+ export interface RuntimeParseNamespace {
399
+ cookie(response: Pick<RuntimeResponse, "headers">, cookieName: string): string | null;
400
+ json<T = unknown>(response: Pick<RuntimeResponse, "body">): T;
401
+ safeJson: typeof safeJson;
402
+ sse(body: string): Array<{ event: string | null; data: unknown }>;
403
+ sseEvent<T = unknown>(body: string, eventName: string): T | null;
404
+ }
405
+
406
+ export interface RuntimeChecksNamespace {
407
+ authGate: typeof runAuthGateChecks;
408
+ pagination: typeof runPaginationChecks;
409
+ }
410
+
411
+ export interface RuntimeNetworkNamespace {
412
+ deterministicIp(seed: string | number, offset?: number): string;
413
+ }
414
+
415
+ export declare const expect: RuntimeExpectNamespace;
416
+ export declare const parse: RuntimeParseNamespace;
417
+ export declare const checks: RuntimeChecksNamespace;
418
+ export declare const network: RuntimeNetworkNamespace;
269
419
 
270
420
  declare global {
271
421
  const __ENV: Record<string, string | undefined>;
@@ -1,6 +1,13 @@
1
1
  import rawHttp from "k6/http";
2
2
  import { Rate, Trend } from "k6/metrics";
3
3
  import { fail, sleep } from "k6";
4
+ import { deriveDeterministicIp } from "../runtime-src/shared/error-body.mjs";
5
+ import {
6
+ extractCookie,
7
+ getSseEventData,
8
+ parseJsonBody,
9
+ parseSseEvents,
10
+ } from "../runtime-src/shared/http-parsing.mjs";
4
11
  import {
5
12
  formatWaitForTimeoutError,
6
13
  normalizeWaitIntervalSeconds,
@@ -27,17 +34,26 @@ export {
27
34
  contains,
28
35
  defaultOptions,
29
36
  evaluateCheck,
30
- isSorted,
31
37
  json,
38
+ isSorted,
32
39
  singleIterationOptions,
33
40
  } from "../runtime-src/k6/checks.js";
34
41
  export {
42
+ expectCondition,
43
+ expectErrorMessage,
44
+ expectErrorShape,
35
45
  expectJson,
36
46
  expectJsonPath,
37
47
  expectNotStatus,
48
+ expectResponse,
38
49
  expectStatus,
39
50
  expectStatusOneOf,
51
+ expectValue,
40
52
  } from "../runtime-src/k6/http-assertions.js";
53
+ export {
54
+ runAuthGateChecks,
55
+ runPaginationChecks,
56
+ } from "../runtime-src/k6/http-checks.js";
41
57
  export {
42
58
  createDalContext,
43
59
  openDb,
@@ -48,14 +64,63 @@ export {
48
64
  defaultOptions as httpDefaultOptions,
49
65
  getEnv,
50
66
  getHttpTrace,
51
- makeGetWithHeaders,
52
- makeRawReq,
53
67
  makeReq,
68
+ makeRawReq,
54
69
  safeJson,
55
70
  summarizeHttpTrace,
56
71
  toBodyPreview,
57
72
  } from "../runtime-src/k6/http.js";
58
73
 
74
+ import {
75
+ expectCondition,
76
+ expectErrorMessage,
77
+ expectErrorShape,
78
+ expectJson,
79
+ expectJsonPath,
80
+ expectNotStatus,
81
+ expectResponse,
82
+ expectStatus,
83
+ expectStatusOneOf,
84
+ expectValue,
85
+ } from "../runtime-src/k6/http-assertions.js";
86
+ import {
87
+ runAuthGateChecks,
88
+ runPaginationChecks,
89
+ } from "../runtime-src/k6/http-checks.js";
90
+ import { safeJson } from "../runtime-src/k6/http.js";
91
+
92
+ export const expect = {
93
+ condition: expectCondition,
94
+ error: {
95
+ message: expectErrorMessage,
96
+ shape: expectErrorShape,
97
+ },
98
+ json: expectJson,
99
+ jsonPath: expectJsonPath,
100
+ notStatus: expectNotStatus,
101
+ response: expectResponse,
102
+ status: expectStatus,
103
+ statusOneOf: expectStatusOneOf,
104
+ value: expectValue,
105
+ };
106
+
107
+ export const parse = {
108
+ cookie: extractCookie,
109
+ json: parseJsonBody,
110
+ safeJson,
111
+ sse: parseSseEvents,
112
+ sseEvent: getSseEventData,
113
+ };
114
+
115
+ export const checks = {
116
+ authGate: runAuthGateChecks,
117
+ pagination: runPaginationChecks,
118
+ };
119
+
120
+ export const network = {
121
+ deterministicIp: deriveDeterministicIp,
122
+ };
123
+
59
124
  export function getTestkitContext() {
60
125
  return readTestkitContext(__ENV);
61
126
  }
@@ -1,4 +1,8 @@
1
- import { evaluateCheck, recordFailureDetail } from "./checks.js";
1
+ import { check, evaluateCheck, recordFailureDetail } from "./checks.js";
2
+ import {
3
+ extractErrorMessageFromBody,
4
+ hasStandardErrorShape,
5
+ } from "../shared/error-body.mjs";
2
6
  import { safeJson, summarizeHttpTrace, toBodyPreview } from "./http.js";
3
7
 
4
8
  export function expectStatus(response, expected, label = null) {
@@ -122,6 +126,32 @@ export function expectJsonPath(response, path, predicate, label = null) {
122
126
  );
123
127
  }
124
128
 
129
+ export function expectErrorShape(response, label = "response has standard error shape") {
130
+ return expectJson(response, hasStandardErrorShape, label);
131
+ }
132
+
133
+ export function expectErrorMessage(response, label = "response includes extractable error message") {
134
+ return expectJson(response, (body) => extractErrorMessageFromBody(body) !== null, label);
135
+ }
136
+
137
+ export function expectResponse(response, predicate, label) {
138
+ return check(response, {
139
+ [normalizeLabel(label, "response matches expectation")]: predicate,
140
+ });
141
+ }
142
+
143
+ export function expectValue(value, predicate, label) {
144
+ return check(value, {
145
+ [normalizeLabel(label, "value matches expectation")]: predicate,
146
+ });
147
+ }
148
+
149
+ export function expectCondition(predicate, label) {
150
+ return check(null, {
151
+ [normalizeLabel(label, "condition holds")]: () => Boolean(predicate()),
152
+ });
153
+ }
154
+
125
155
  function buildHttpAssertionDetail({ kind, title, trace, expected, actual, response, message }) {
126
156
  return {
127
157
  kind,
@@ -0,0 +1,120 @@
1
+ import { group } from "./checks.js";
2
+ import {
3
+ expectErrorShape,
4
+ expectNotStatus,
5
+ expectResponse,
6
+ expectStatus,
7
+ expectStatusOneOf,
8
+ } from "./http-assertions.js";
9
+
10
+ const DEFAULT_PAGINATION_CASES = [
11
+ { qs: "limit=0", label: "limit=0", expect400: false },
12
+ { qs: "limit=-1", label: "limit=-1", expect400: true },
13
+ { qs: "limit=999999", label: "limit=999999", expect400: false },
14
+ { qs: "limit=abc", label: "limit=abc", expect400: true },
15
+ { qs: "offset=-1", label: "offset=-1", expect400: true },
16
+ { qs: "offset=1.5", label: "offset=1.5", expect400: true },
17
+ ];
18
+
19
+ const AUDIT_LOGS_PAGINATION_CASES = [
20
+ { qs: "limit=1e3", label: "limit=1e3 (scientific notation)" },
21
+ { qs: "limit=Infinity", label: "limit=Infinity" },
22
+ { qs: "limit=NaN", label: "limit=NaN" },
23
+ { qs: "offset=NaN", label: "offset=NaN" },
24
+ { qs: "limit=", label: "limit= (empty)" },
25
+ { qs: "offset=", label: "offset= (empty)" },
26
+ { qs: "limit=0x10", label: "limit=0x10 (hex)" },
27
+ ];
28
+
29
+ export function runAuthGateChecks(rawReq, scope, descriptors = {}) {
30
+ const {
31
+ get = [],
32
+ post = [],
33
+ patch = [],
34
+ delete: deleteCases = [],
35
+ put = [],
36
+ validateGetErrorShape = false,
37
+ } = descriptors;
38
+
39
+ runMethodAuthGateChecks(rawReq, scope, "GET", get, validateGetErrorShape);
40
+ runMethodAuthGateChecks(rawReq, scope, "POST", post);
41
+ runMethodAuthGateChecks(rawReq, scope, "PATCH", patch);
42
+ runMethodAuthGateChecks(rawReq, scope, "DELETE", deleteCases);
43
+ runMethodAuthGateChecks(rawReq, scope, "PUT", put);
44
+ }
45
+
46
+ export function runPaginationChecks(req, endpoint, options = {}) {
47
+ group(`${endpoint} — pagination abuse`, () => {
48
+ for (const { qs, label, expect400 } of DEFAULT_PAGINATION_CASES) {
49
+ const url = `${endpoint}?${qs}`;
50
+ const response = req.get(url);
51
+
52
+ expectNotStatus(response, 500, `${label} → not 500`);
53
+ if (response.status === 500) {
54
+ expectResponse(response, () => true, `BUG: ${endpoint} crashes on ${label}`);
55
+ }
56
+
57
+ if (expect400) {
58
+ expectStatus(response, 400, `${label} → 400`);
59
+ if (response.status === 200) {
60
+ expectResponse(response, () => true, `BUG: ${endpoint} accepts ${label}`);
61
+ }
62
+ }
63
+
64
+ if (label === "limit=abc" && response.body) {
65
+ expectResponse(response, (value) => !value.body.includes("NaN"), `${label} → no NaN in response`);
66
+ }
67
+ }
68
+
69
+ if (!options.auditLogsExtra) {
70
+ return;
71
+ }
72
+
73
+ for (const { qs, label } of AUDIT_LOGS_PAGINATION_CASES) {
74
+ const url = `${endpoint}?${qs}`;
75
+ const response = req.get(url);
76
+
77
+ expectNotStatus(response, 500, `audit-logs ${label} → not 500`);
78
+ if (response.status === 500) {
79
+ expectResponse(response, () => true, `BUG: audit-logs crashes on ${label}`);
80
+ }
81
+
82
+ expectStatusOneOf(response, [400, 200], `audit-logs ${label} → 400 (not silently accepted)`);
83
+ if (response.status === 200 && response.body) {
84
+ expectResponse(response, (value) => !value.body.includes("NaN"), `audit-logs ${label} → no NaN in response`);
85
+ }
86
+ }
87
+ });
88
+ }
89
+
90
+ function runMethodAuthGateChecks(rawReq, scope, method, cases, validateErrorShape = false) {
91
+ if (!Array.isArray(cases) || cases.length === 0) {
92
+ return;
93
+ }
94
+
95
+ group(`${scope} ${method} endpoints return 401 without auth`, () => {
96
+ for (const entry of cases) {
97
+ const { path, body } = normalizeRequestCase(entry);
98
+ const response = rawReq(method, path, body);
99
+
100
+ expectStatus(response, 401, `${method} ${path} → 401`);
101
+ if (validateErrorShape && method === "GET" && response.status === 401) {
102
+ expectErrorShape(response, `${method} ${path} error shape`);
103
+ }
104
+ }
105
+ });
106
+ }
107
+
108
+ function normalizeRequestCase(entry) {
109
+ if (Array.isArray(entry)) {
110
+ return {
111
+ path: entry[0],
112
+ body: entry[1],
113
+ };
114
+ }
115
+
116
+ return {
117
+ path: entry,
118
+ body: undefined,
119
+ };
120
+ }
@@ -0,0 +1,120 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mockCheck = vi.fn((value, checks) =>
4
+ Object.values(checks).every((predicate) => predicate(value))
5
+ );
6
+ const mockGroup = vi.fn((name, fn) => fn());
7
+
8
+ vi.mock("k6", () => ({
9
+ check: mockCheck,
10
+ group: mockGroup,
11
+ }));
12
+
13
+ vi.mock("k6/http", () => ({
14
+ default: {
15
+ del: vi.fn(),
16
+ file: vi.fn(),
17
+ get: vi.fn(),
18
+ patch: vi.fn(),
19
+ post: vi.fn(),
20
+ put: vi.fn(),
21
+ },
22
+ }));
23
+
24
+ vi.mock("k6/metrics", () => ({
25
+ Rate: class {
26
+ add() {}
27
+ },
28
+ }));
29
+
30
+ const { runAuthGateChecks, runPaginationChecks } = await import("./http-checks.js");
31
+
32
+ function makeJsonResponse(status, body) {
33
+ return {
34
+ status,
35
+ body: JSON.stringify(body),
36
+ headers: { "content-type": "application/json" },
37
+ };
38
+ }
39
+
40
+ describe("runtime http checks", () => {
41
+ beforeEach(() => {
42
+ mockCheck.mockClear();
43
+ mockGroup.mockClear();
44
+ });
45
+
46
+ it("executes auth-gate checks across configured methods", () => {
47
+ const rawReq = vi.fn((method, path, body) => {
48
+ return makeJsonResponse(401, {
49
+ error: {
50
+ code: "UNAUTHORIZED",
51
+ message: `${method} ${path} requires auth`,
52
+ },
53
+ body,
54
+ });
55
+ });
56
+
57
+ runAuthGateChecks(rawReq, "sessions", {
58
+ get: ["/api/v1/sessions"],
59
+ post: [["/api/v1/sessions", { name: "draft" }]],
60
+ validateGetErrorShape: true,
61
+ });
62
+
63
+ expect(rawReq.mock.calls).toEqual([
64
+ ["GET", "/api/v1/sessions", undefined],
65
+ ["POST", "/api/v1/sessions", { name: "draft" }],
66
+ ]);
67
+ expect(mockGroup).toHaveBeenCalledWith(
68
+ "sessions GET endpoints return 401 without auth",
69
+ expect.any(Function)
70
+ );
71
+ expect(mockGroup).toHaveBeenCalledWith(
72
+ "sessions POST endpoints return 401 without auth",
73
+ expect.any(Function)
74
+ );
75
+ });
76
+
77
+ it("executes default and audit-log pagination abuse cases", () => {
78
+ const requested = [];
79
+ const req = {
80
+ get: vi.fn((url) => {
81
+ requested.push(url);
82
+ const expects400 =
83
+ url.includes("limit=-1") ||
84
+ url.includes("limit=abc") ||
85
+ url.includes("offset=-1") ||
86
+ url.includes("offset=1.5") ||
87
+ url.includes("limit=Infinity") ||
88
+ url.includes("limit=NaN") ||
89
+ url.includes("offset=NaN") ||
90
+ url.endsWith("limit=") ||
91
+ url.endsWith("offset=");
92
+
93
+ if (expects400) {
94
+ return makeJsonResponse(400, {
95
+ error: {
96
+ code: "VALIDATION_ERROR",
97
+ message: "invalid pagination",
98
+ },
99
+ });
100
+ }
101
+
102
+ return makeJsonResponse(200, {
103
+ data: [],
104
+ pagination: {
105
+ limit: 25,
106
+ offset: 0,
107
+ },
108
+ });
109
+ }),
110
+ };
111
+
112
+ runPaginationChecks(req, "/api/v1/audit-logs", { auditLogsExtra: true });
113
+
114
+ expect(requested).toHaveLength(13);
115
+ expect(requested).toContain("/api/v1/audit-logs?limit=-1");
116
+ expect(requested).toContain("/api/v1/audit-logs?offset=1.5");
117
+ expect(requested).toContain("/api/v1/audit-logs?limit=Infinity");
118
+ expect(requested).toContain("/api/v1/audit-logs?limit=0x10");
119
+ });
120
+ });