@elench/testkit 0.1.82 → 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 (50) hide show
  1. package/README.md +37 -7
  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 +15 -10
  31. package/lib/config-api/index.test.mjs +54 -0
  32. package/lib/discovery/index.mjs +1 -1
  33. package/lib/index.d.ts +5 -1
  34. package/lib/runner/orchestrator.mjs +1 -0
  35. package/lib/runtime/index.d.ts +138 -5
  36. package/lib/runtime/index.mjs +68 -2
  37. package/lib/runtime-src/k6/http-assertions.js +31 -1
  38. package/lib/runtime-src/k6/http-checks.js +120 -0
  39. package/lib/runtime-src/k6/http-checks.test.mjs +120 -0
  40. package/lib/runtime-src/k6/http-suite-runtime.js +5 -1
  41. package/lib/runtime-src/k6/http.js +213 -23
  42. package/lib/runtime-src/k6/http.test.mjs +205 -0
  43. package/lib/runtime-src/shared/error-body.mjs +42 -0
  44. package/lib/runtime-src/shared/http-parsing.mjs +68 -0
  45. package/lib/runtime-src/shared/http-parsing.test.mjs +69 -0
  46. package/node_modules/@elench/next-analysis/package.json +1 -1
  47. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  48. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  49. package/node_modules/@elench/ts-analysis/package.json +1 -1
  50. package/package.json +5 -5
@@ -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,13 +64,63 @@ export {
48
64
  defaultOptions as httpDefaultOptions,
49
65
  getEnv,
50
66
  getHttpTrace,
51
- makeRawReq,
52
67
  makeReq,
68
+ makeRawReq,
53
69
  safeJson,
54
70
  summarizeHttpTrace,
55
71
  toBodyPreview,
56
72
  } from "../runtime-src/k6/http.js";
57
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
+
58
124
  export function getTestkitContext() {
59
125
  return readTestkitContext(__ENV);
60
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
+ });
@@ -64,14 +64,18 @@ export function createSuiteActors(sessionBundle, client, profileMeta) {
64
64
  const actorNames = profileMeta?.actorNames || Object.keys(sessionBundle?.actors || {});
65
65
  const entries = actorNames.map((actorName) => {
66
66
  const actorRecord = sessionBundle?.actors?.[actorName] || null;
67
+ const actorClient = client.as(actorName);
67
68
  return {
68
69
  email: actorRecord?.email || null,
70
+ headers: actorClient.headers(),
69
71
  index: Number(actorRecord?.actorIndex ?? 0),
70
72
  key: actorName,
71
73
  name: actorRecord?.name || null,
72
74
  organizationKey: actorRecord?.organizationKey || null,
73
75
  organizationName: actorRecord?.organizationName || null,
74
- req: client.as(actorName),
76
+ rawHeaders: actorClient.rawHeaders(),
77
+ rawReq: actorClient.rawReq,
78
+ req: actorClient,
75
79
  session: actorRecord?.session || null,
76
80
  };
77
81
  });