@cspeach/cli 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -108
- package/dist/agent/__tests__/crash-recover.integration.test.js +254 -0
- package/dist/agent/__tests__/loop-save-hook.test.js +188 -0
- package/dist/agent/__tests__/maybe-build-project-context.test.js +247 -0
- package/dist/agent/__tests__/project-context-injection.test.js +161 -0
- package/dist/agent/__tests__/repair-partial.test.js +79 -0
- package/dist/agent/__tests__/retry-key.test.js +77 -0
- package/dist/agent/__tests__/sap-connection-adapter.test.js +132 -0
- package/dist/agent/__tests__/skill-checkpoint.test.js +154 -0
- package/dist/agent/__tests__/turn-assistant-text.test.js +73 -0
- package/dist/agent/__tests__/turn-error-ux.test.js +145 -0
- package/dist/agent/__tests__/turn-stream.test.js +100 -0
- package/dist/agent/__tests__/turn-watchdog.test.js +126 -0
- package/dist/agent/providers/__tests__/ai-hub-provider.test.js +30 -0
- package/dist/agent/providers/__tests__/byok-provider.test.js +32 -0
- package/dist/agent/providers/__tests__/factory.test.js +33 -0
- package/dist/agent/providers/__tests__/local-provider.test.js +55 -0
- package/dist/approvals/__tests__/advisory-prompt.test.js +57 -0
- package/dist/approvals/__tests__/advisory-render.test.js +54 -0
- package/dist/auth/__tests__/auth-file.test.js +42 -0
- package/dist/auth/__tests__/me.test.js +43 -0
- package/dist/classifier/__tests__/client.test.js +70 -0
- package/dist/commands/__tests__/config-set-write-mode.test.js +14 -0
- package/dist/commands/__tests__/login.test.js +152 -0
- package/dist/commands/__tests__/logout.test.js +39 -0
- package/dist/commands/__tests__/project-context-impact.test.js +173 -0
- package/dist/commands/__tests__/spec-gap-status.test.js +31 -0
- package/dist/commands/__tests__/whoami.test.js +91 -0
- package/dist/config/__tests__/llm-config.test.js +28 -0
- package/dist/config/__tests__/shell-exec-config.test.js +76 -0
- package/dist/config/__tests__/write-mode.test.js +36 -0
- package/dist/doctor/__tests__/check-forge-rules.test.js +134 -0
- package/dist/doctor/__tests__/check-llm-mode.test.js +21 -0
- package/dist/doctor/__tests__/check-write-mode.test.js +34 -0
- package/dist/index.js +0 -0
- package/dist/project-context/__tests__/conventions.test.js +221 -0
- package/dist/project-context/__tests__/detect.test.js +177 -0
- package/dist/project-context/__tests__/domain-abap-cloud.test.js +119 -0
- package/dist/project-context/__tests__/domain-abapgit.test.js +161 -0
- package/dist/project-context/__tests__/domain-cap.test.js +168 -0
- package/dist/project-context/__tests__/domain-fiori.test.js +282 -0
- package/dist/project-context/__tests__/git.test.js +131 -0
- package/dist/project-context/__tests__/index-files.test.js +195 -0
- package/dist/project-context/__tests__/index.test.js +133 -0
- package/dist/project-context/__tests__/render.test.js +322 -0
- package/dist/project-context/__tests__/types.test.js +73 -0
- package/dist/projects/__tests__/build.test.js +52 -0
- package/dist/projects/__tests__/canonicalize.test.js +47 -0
- package/dist/projects/__tests__/email-template.test.js +71 -0
- package/dist/projects/__tests__/expand-text-attachments.test.js +105 -0
- package/dist/projects/__tests__/extract-cca.test.js +141 -0
- package/dist/projects/__tests__/extract-design.test.js +62 -0
- package/dist/projects/__tests__/extract-estimate.test.js +58 -0
- package/dist/projects/__tests__/extract-modernize.test.js +127 -0
- package/dist/projects/__tests__/extract-spec-gap.test.js +122 -0
- package/dist/projects/__tests__/extract-test-coverage.test.js +133 -0
- package/dist/projects/__tests__/filename.test.js +32 -0
- package/dist/projects/__tests__/promote-command.test.js +170 -0
- package/dist/projects/__tests__/promote.test.js +181 -0
- package/dist/projects/__tests__/save-command.test.js +216 -0
- package/dist/projects/__tests__/save.test.js +41 -0
- package/dist/projects/__tests__/status.test.js +208 -0
- package/dist/projects/__tests__/types.test.js +41 -0
- package/dist/projects/__tests__/validate.test.js +165 -0
- package/dist/projects/__tests__/workspace.test.js +337 -0
- package/dist/repl/__tests__/current-transport.test.js +43 -0
- package/dist/repl/__tests__/diff-display.test.js +59 -0
- package/dist/repl/__tests__/file-picker.test.js +97 -0
- package/dist/repl/__tests__/rule8-detector.test.js +114 -0
- package/dist/repl/__tests__/safety-confirm.test.js +130 -0
- package/dist/repl/__tests__/safety-mode-state.test.js +42 -0
- package/dist/sap/__tests__/system-info.test.js +344 -0
- package/dist/session/__tests__/store-discovery.test.js +114 -0
- package/dist/session/__tests__/time-ago.test.js +53 -0
- package/dist/skills/__tests__/canonical.test.js +20 -0
- package/dist/skills/__tests__/manifest-client.test.js +88 -0
- package/dist/skills/__tests__/promotion-dispatch.test.js +28 -0
- package/dist/skills/__tests__/source-managed.test.js +60 -0
- package/dist/tools/__tests__/_background-shared.test.js +69 -0
- package/dist/tools/__tests__/_command-shared.test.js +59 -0
- package/dist/tools/__tests__/_filesystem-shared.test.js +42 -0
- package/dist/tools/__tests__/_flag.test.js +66 -0
- package/dist/tools/__tests__/_helpers.js +17 -0
- package/dist/tools/__tests__/_web-shared.test.js +143 -0
- package/dist/tools/__tests__/agent_run.test.js +166 -0
- package/dist/tools/__tests__/approval-advisory.test.js +66 -0
- package/dist/tools/__tests__/background_run.test.js +113 -0
- package/dist/tools/__tests__/convention_get.test.js +53 -0
- package/dist/tools/__tests__/file-edit.test.js +72 -0
- package/dist/tools/__tests__/file-read.test.js +81 -0
- package/dist/tools/__tests__/file-write.test.js +68 -0
- package/dist/tools/__tests__/glob.test.js +128 -0
- package/dist/tools/__tests__/grep.test.js +71 -0
- package/dist/tools/__tests__/monitor_emit.test.js +96 -0
- package/dist/tools/__tests__/playbook_get.test.js +109 -0
- package/dist/tools/__tests__/project_context_get.test.js +71 -0
- package/dist/tools/__tests__/registry-category.test.js +60 -0
- package/dist/tools/__tests__/sap-read-extras.test.js +196 -0
- package/dist/tools/__tests__/sap-write-extras.test.js +573 -0
- package/dist/tools/__tests__/schedule_create.test.js +85 -0
- package/dist/tools/__tests__/shell_exec.test.js +135 -0
- package/dist/tools/__tests__/transport-safety.test.js +106 -0
- package/dist/tools/__tests__/update-method-intercept.test.js +128 -0
- package/dist/tools/__tests__/web_fetch.test.js +329 -0
- package/dist/tools/__tests__/web_search.test.js +215 -0
- package/dist/tools/__tests__/write-mode.test.js +32 -0
- package/dist/ui/__tests__/login-banner.test.js +69 -0
- package/package.json +82 -83
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/__tests__/web_fetch.test.ts
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
/**
|
|
5
|
+
* `node:https`'s exported `request` function is a non-configurable own
|
|
6
|
+
* property of the module namespace (Object.getOwnPropertyDescriptor
|
|
7
|
+
* shows `configurable: false`), so `vi.spyOn(https, 'request')` throws
|
|
8
|
+
* "Cannot redefine property". We use `vi.mock('node:https', ...)` —
|
|
9
|
+
* hoisted by vitest before module resolution — so the implementation's
|
|
10
|
+
* `import * as https from 'node:https'` resolves to a controllable
|
|
11
|
+
* mock instead. Each test resets `mockRequest`'s impl via the
|
|
12
|
+
* `setupHttpsMock(responder)` helper.
|
|
13
|
+
*/
|
|
14
|
+
const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn() }));
|
|
15
|
+
vi.mock('node:https', () => ({
|
|
16
|
+
default: { request: mockRequest },
|
|
17
|
+
request: mockRequest,
|
|
18
|
+
}));
|
|
19
|
+
import { webFetchHandler, USER_AGENT } from '../web/web_fetch.js';
|
|
20
|
+
import { makeCtx } from './_helpers.js';
|
|
21
|
+
function setupHttpsMock(responder) {
|
|
22
|
+
const recorded = [];
|
|
23
|
+
let callIndex = 0;
|
|
24
|
+
mockRequest.mockImplementation((...rawArgs) => {
|
|
25
|
+
// https.request can be called as (opts, cb) OR (url, opts, cb) OR (url, cb).
|
|
26
|
+
let opts;
|
|
27
|
+
let cb;
|
|
28
|
+
if (rawArgs.length === 1) {
|
|
29
|
+
opts = rawArgs[0];
|
|
30
|
+
}
|
|
31
|
+
else if (rawArgs.length === 2) {
|
|
32
|
+
if (typeof rawArgs[1] === 'function') {
|
|
33
|
+
opts = rawArgs[0];
|
|
34
|
+
cb = rawArgs[1];
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
opts = { ...rawArgs[0], ...rawArgs[1] };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
opts = { ...rawArgs[0], ...rawArgs[1] };
|
|
42
|
+
cb = rawArgs[2];
|
|
43
|
+
}
|
|
44
|
+
const spec = responder(opts, callIndex);
|
|
45
|
+
callIndex++;
|
|
46
|
+
recorded.push({ options: opts, spec });
|
|
47
|
+
const req = new EventEmitter();
|
|
48
|
+
req.setTimeout = (ms, fn) => {
|
|
49
|
+
if (spec.kind === 'timeout' && fn)
|
|
50
|
+
setTimeout(fn, 0);
|
|
51
|
+
};
|
|
52
|
+
req.destroy = (err) => { req.emit('close'); if (err)
|
|
53
|
+
req.emit('error', err); };
|
|
54
|
+
req.end = () => {
|
|
55
|
+
if (spec.kind === 'response' && cb) {
|
|
56
|
+
const res = new EventEmitter();
|
|
57
|
+
res.statusCode = spec.statusCode;
|
|
58
|
+
res.headers = spec.headers;
|
|
59
|
+
// Real Node streams' destroy() emits 'close' but NOT 'end'.
|
|
60
|
+
// The mock must mirror that contract: once destroy() has been
|
|
61
|
+
// called, no further 'data' or 'end' events fire. Otherwise the
|
|
62
|
+
// mock papers over production code that relies on 'end' firing
|
|
63
|
+
// after destroy() — which it doesn't, in real Node.
|
|
64
|
+
let destroyed = false;
|
|
65
|
+
res.destroy = () => { destroyed = true; };
|
|
66
|
+
setImmediate(() => {
|
|
67
|
+
cb(res);
|
|
68
|
+
setImmediate(() => {
|
|
69
|
+
if (spec.bodyChunks) {
|
|
70
|
+
for (const chunk of spec.bodyChunks) {
|
|
71
|
+
if (destroyed)
|
|
72
|
+
break;
|
|
73
|
+
res.emit('data', chunk);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
if (!destroyed)
|
|
78
|
+
res.emit('data', Buffer.isBuffer(spec.body) ? spec.body : Buffer.from(spec.body, 'utf-8'));
|
|
79
|
+
}
|
|
80
|
+
if (!destroyed)
|
|
81
|
+
res.emit('end');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
else if (spec.kind === 'error') {
|
|
86
|
+
setImmediate(() => req.emit('error', spec.error));
|
|
87
|
+
}
|
|
88
|
+
// 'timeout' kind: setTimeout fired via req.setTimeout above
|
|
89
|
+
};
|
|
90
|
+
return req;
|
|
91
|
+
});
|
|
92
|
+
return { recorded };
|
|
93
|
+
}
|
|
94
|
+
const fakeDeps = (addresses) => ({
|
|
95
|
+
resolveAll: async () => addresses,
|
|
96
|
+
});
|
|
97
|
+
const ctx = () => makeCtx(process.cwd());
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
delete process.env.CSPEACH_WEB_ALLOW_PRIVATE;
|
|
100
|
+
mockRequest.mockReset();
|
|
101
|
+
});
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
delete process.env.CSPEACH_WEB_ALLOW_PRIVATE;
|
|
104
|
+
mockRequest.mockReset();
|
|
105
|
+
});
|
|
106
|
+
describe('web_fetch — happy path', () => {
|
|
107
|
+
it('fetches a public HTTPS URL and returns its body + status + headers', async () => {
|
|
108
|
+
setupHttpsMock(() => ({
|
|
109
|
+
kind: 'response',
|
|
110
|
+
statusCode: 200,
|
|
111
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
112
|
+
body: 'hello world',
|
|
113
|
+
}));
|
|
114
|
+
const r = await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
115
|
+
expect(r.is_error).toBeFalsy();
|
|
116
|
+
expect(r.content).toContain('status: 200');
|
|
117
|
+
expect(r.content).toContain('hello world');
|
|
118
|
+
});
|
|
119
|
+
it('sends our User-Agent header on every request', async () => {
|
|
120
|
+
const { recorded } = setupHttpsMock(() => ({
|
|
121
|
+
kind: 'response',
|
|
122
|
+
statusCode: 200,
|
|
123
|
+
headers: { 'content-type': 'text/plain' },
|
|
124
|
+
body: 'ok',
|
|
125
|
+
}));
|
|
126
|
+
await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
127
|
+
const sentUa = recorded[0].options.headers?.['User-Agent'] ?? recorded[0].options.headers?.['user-agent'];
|
|
128
|
+
expect(sentUa).toBe(USER_AGENT);
|
|
129
|
+
expect(USER_AGENT).toMatch(/^cspeach\/\d+\.\d+ \(\+https:\/\/cspeach\.dev\)$/);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe('web_fetch — URL / protocol / IP guards', () => {
|
|
133
|
+
it('refuses http:// (HTTPS-only)', async () => {
|
|
134
|
+
const r = await webFetchHandler({ url: 'http://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
135
|
+
expect(r.is_error).toBe(true);
|
|
136
|
+
expect(r.content).toMatch(/protocol|https/i);
|
|
137
|
+
});
|
|
138
|
+
it('refuses literal localhost hostname', async () => {
|
|
139
|
+
const r = await webFetchHandler({ url: 'https://localhost/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
140
|
+
expect(r.is_error).toBe(true);
|
|
141
|
+
expect(r.content).toMatch(/blocked|localhost/i);
|
|
142
|
+
});
|
|
143
|
+
it('refuses a 10.x.x.x literal IP', async () => {
|
|
144
|
+
const r = await webFetchHandler({ url: 'https://10.0.0.1/' }, ctx(), { _deps: fakeDeps([]) });
|
|
145
|
+
expect(r.is_error).toBe(true);
|
|
146
|
+
expect(r.content).toMatch(/private|reserved/i);
|
|
147
|
+
});
|
|
148
|
+
it('refuses a public hostname that resolves to 169.254.169.254 (cloud metadata)', async () => {
|
|
149
|
+
const r = await webFetchHandler({ url: 'https://attacker.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '169.254.169.254', family: 4 }]) });
|
|
150
|
+
expect(r.is_error).toBe(true);
|
|
151
|
+
expect(r.content).toMatch(/private|metadata/i);
|
|
152
|
+
});
|
|
153
|
+
it('refuses a public hostname that resolves to 127.0.0.1 (DNS-rebinding)', async () => {
|
|
154
|
+
const r = await webFetchHandler({ url: 'https://innocent.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '127.0.0.1', family: 4 }]) });
|
|
155
|
+
expect(r.is_error).toBe(true);
|
|
156
|
+
expect(r.content).toMatch(/private|loopback/i);
|
|
157
|
+
});
|
|
158
|
+
it('allows a private IP when CSPEACH_WEB_ALLOW_PRIVATE=on', async () => {
|
|
159
|
+
process.env.CSPEACH_WEB_ALLOW_PRIVATE = 'on';
|
|
160
|
+
setupHttpsMock(() => ({
|
|
161
|
+
kind: 'response',
|
|
162
|
+
statusCode: 200,
|
|
163
|
+
headers: { 'content-type': 'text/plain' },
|
|
164
|
+
body: 'dev server',
|
|
165
|
+
}));
|
|
166
|
+
const r = await webFetchHandler({ url: 'https://dev.local.test/' }, ctx(), { _deps: fakeDeps([{ address: '127.0.0.1', family: 4 }]) });
|
|
167
|
+
expect(r.is_error).toBeFalsy();
|
|
168
|
+
expect(r.content).toContain('dev server');
|
|
169
|
+
});
|
|
170
|
+
it('passes a pinnedLookup that returns the validated address, not a fresh DNS query', async () => {
|
|
171
|
+
const { recorded } = setupHttpsMock(() => ({
|
|
172
|
+
kind: 'response',
|
|
173
|
+
statusCode: 200,
|
|
174
|
+
headers: { 'content-type': 'text/plain' },
|
|
175
|
+
body: 'ok',
|
|
176
|
+
}));
|
|
177
|
+
await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
178
|
+
const lookup = recorded[0].options.lookup;
|
|
179
|
+
expect(typeof lookup).toBe('function');
|
|
180
|
+
const result = await new Promise((resolve, reject) => {
|
|
181
|
+
lookup('example.com', {}, (err, address, family) => {
|
|
182
|
+
if (err)
|
|
183
|
+
reject(err);
|
|
184
|
+
else
|
|
185
|
+
resolve({ addr: address, fam: family });
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
expect(result.addr).toBe('93.184.216.34');
|
|
189
|
+
expect(result.fam).toBe(4);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe('web_fetch — redirects', () => {
|
|
193
|
+
it('follows a single 302 redirect to a different public host', async () => {
|
|
194
|
+
let call = 0;
|
|
195
|
+
setupHttpsMock((opts) => {
|
|
196
|
+
call++;
|
|
197
|
+
if (call === 1) {
|
|
198
|
+
return {
|
|
199
|
+
kind: 'response',
|
|
200
|
+
statusCode: 302,
|
|
201
|
+
headers: { 'content-type': 'text/plain', location: 'https://target.example.com/final' },
|
|
202
|
+
body: '',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
kind: 'response',
|
|
207
|
+
statusCode: 200,
|
|
208
|
+
headers: { 'content-type': 'text/plain' },
|
|
209
|
+
body: 'final page',
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
const r = await webFetchHandler({ url: 'https://start.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
213
|
+
expect(r.is_error).toBeFalsy();
|
|
214
|
+
expect(r.content).toContain('final page');
|
|
215
|
+
expect(r.content).toMatch(/redirects: 1/i);
|
|
216
|
+
});
|
|
217
|
+
it('refuses a redirect that points at a private IP', async () => {
|
|
218
|
+
let call = 0;
|
|
219
|
+
setupHttpsMock(() => {
|
|
220
|
+
call++;
|
|
221
|
+
if (call === 1) {
|
|
222
|
+
return {
|
|
223
|
+
kind: 'response',
|
|
224
|
+
statusCode: 302,
|
|
225
|
+
headers: { 'content-type': 'text/plain', location: 'https://10.0.0.1/admin' },
|
|
226
|
+
body: '',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return { kind: 'response', statusCode: 200, headers: { 'content-type': 'text/plain' }, body: 'should never reach' };
|
|
230
|
+
});
|
|
231
|
+
const r = await webFetchHandler({ url: 'https://start.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
232
|
+
expect(r.is_error).toBe(true);
|
|
233
|
+
expect(r.content).toMatch(/private|redirect/i);
|
|
234
|
+
expect(call).toBe(1); // we never made the second request
|
|
235
|
+
});
|
|
236
|
+
it('refuses a redirect that downgrades to http://', async () => {
|
|
237
|
+
setupHttpsMock(() => ({
|
|
238
|
+
kind: 'response',
|
|
239
|
+
statusCode: 302,
|
|
240
|
+
headers: { 'content-type': 'text/plain', location: 'http://example.com/insecure' },
|
|
241
|
+
body: '',
|
|
242
|
+
}));
|
|
243
|
+
const r = await webFetchHandler({ url: 'https://start.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
244
|
+
expect(r.is_error).toBe(true);
|
|
245
|
+
expect(r.content).toMatch(/protocol|https/i);
|
|
246
|
+
});
|
|
247
|
+
it('aborts after 5 hops with a structured error', async () => {
|
|
248
|
+
let call = 0;
|
|
249
|
+
setupHttpsMock(() => {
|
|
250
|
+
call++;
|
|
251
|
+
return {
|
|
252
|
+
kind: 'response',
|
|
253
|
+
statusCode: 302,
|
|
254
|
+
headers: { 'content-type': 'text/plain', location: `https://hop${call}.example.com/` },
|
|
255
|
+
body: '',
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
const r = await webFetchHandler({ url: 'https://start.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
259
|
+
expect(r.is_error).toBe(true);
|
|
260
|
+
expect(r.content).toMatch(/redirect.*limit|too many redirects/i);
|
|
261
|
+
// 1 initial + 5 redirect hops attempted, then refused — exact-count
|
|
262
|
+
// is the invariant; loosening to <=6 would mask a regression that
|
|
263
|
+
// aborts at hop 3.
|
|
264
|
+
expect(call).toBe(6);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
describe('web_fetch — response handling', () => {
|
|
268
|
+
it('refuses application/octet-stream and other non-allowlisted content-types', async () => {
|
|
269
|
+
setupHttpsMock(() => ({
|
|
270
|
+
kind: 'response',
|
|
271
|
+
statusCode: 200,
|
|
272
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
273
|
+
body: Buffer.from([0x00, 0x01, 0x02]),
|
|
274
|
+
}));
|
|
275
|
+
const r = await webFetchHandler({ url: 'https://example.com/data.bin' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
276
|
+
expect(r.is_error).toBe(true);
|
|
277
|
+
expect(r.content).toMatch(/content.?type|allowed/i);
|
|
278
|
+
});
|
|
279
|
+
it('accepts text/html, application/json, application/xml', async () => {
|
|
280
|
+
for (const ct of ['text/html; charset=utf-8', 'application/json', 'application/xml']) {
|
|
281
|
+
setupHttpsMock(() => ({ kind: 'response', statusCode: 200, headers: { 'content-type': ct }, body: 'ok' }));
|
|
282
|
+
const r = await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
283
|
+
expect(r.is_error).toBeFalsy();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
it('truncates a response that exceeds 1 MB and appends a marker', async () => {
|
|
287
|
+
const giant = 'A'.repeat(2_000_000);
|
|
288
|
+
setupHttpsMock(() => ({
|
|
289
|
+
kind: 'response',
|
|
290
|
+
statusCode: 200,
|
|
291
|
+
headers: { 'content-type': 'text/plain' },
|
|
292
|
+
bodyChunks: [Buffer.from(giant.slice(0, 500_000)), Buffer.from(giant.slice(500_000, 1_000_000)), Buffer.from(giant.slice(1_000_000))],
|
|
293
|
+
body: '',
|
|
294
|
+
}));
|
|
295
|
+
const r = await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
296
|
+
expect(r.is_error).toBeFalsy();
|
|
297
|
+
expect(r.content).toMatch(/\[truncated/i);
|
|
298
|
+
expect(r.content.length).toBeLessThan(1_200_000);
|
|
299
|
+
});
|
|
300
|
+
it('reports a 10s timeout as a structured error', async () => {
|
|
301
|
+
setupHttpsMock(() => ({ kind: 'timeout' }));
|
|
302
|
+
const r = await webFetchHandler({ url: 'https://slow.example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]), _timeoutMs: 50 });
|
|
303
|
+
expect(r.is_error).toBe(true);
|
|
304
|
+
expect(r.content).toMatch(/timeout|timed out/i);
|
|
305
|
+
});
|
|
306
|
+
it('rejects responses with Content-Encoding other than identity', async () => {
|
|
307
|
+
setupHttpsMock(() => ({
|
|
308
|
+
kind: 'response',
|
|
309
|
+
statusCode: 200,
|
|
310
|
+
headers: { 'content-type': 'text/html', 'content-encoding': 'gzip' },
|
|
311
|
+
body: Buffer.from([0x1f, 0x8b, 0x08]), // gzip magic header, just to be realistic
|
|
312
|
+
}));
|
|
313
|
+
const r = await webFetchHandler({ url: 'https://example.com/' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
314
|
+
expect(r.is_error).toBe(true);
|
|
315
|
+
expect(r.content).toMatch(/content.?encoding|gzip|identity/i);
|
|
316
|
+
});
|
|
317
|
+
it('surfaces non-2xx status + body WITHOUT marking is_error (the agent sees the 404)', async () => {
|
|
318
|
+
setupHttpsMock(() => ({
|
|
319
|
+
kind: 'response',
|
|
320
|
+
statusCode: 404,
|
|
321
|
+
headers: { 'content-type': 'text/html' },
|
|
322
|
+
body: '<h1>Not Found</h1>',
|
|
323
|
+
}));
|
|
324
|
+
const r = await webFetchHandler({ url: 'https://example.com/missing' }, ctx(), { _deps: fakeDeps([{ address: '93.184.216.34', family: 4 }]) });
|
|
325
|
+
expect(r.is_error).toBeFalsy();
|
|
326
|
+
expect(r.content).toMatch(/status:\s*404/);
|
|
327
|
+
expect(r.content).toContain('Not Found');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/__tests__/web_search.test.ts
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { webSearchHandler } from '../web/web_search.js';
|
|
5
|
+
import { makeCtx } from './_helpers.js';
|
|
6
|
+
const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn() }));
|
|
7
|
+
vi.mock('node:https', () => ({
|
|
8
|
+
default: { request: mockRequest },
|
|
9
|
+
request: mockRequest,
|
|
10
|
+
}));
|
|
11
|
+
function setupHttpsMock(responder) {
|
|
12
|
+
const recorded = [];
|
|
13
|
+
let callIndex = 0;
|
|
14
|
+
mockRequest.mockImplementation((...rawArgs) => {
|
|
15
|
+
let opts;
|
|
16
|
+
let cb;
|
|
17
|
+
if (rawArgs.length === 1)
|
|
18
|
+
opts = rawArgs[0];
|
|
19
|
+
else if (rawArgs.length === 2) {
|
|
20
|
+
if (typeof rawArgs[1] === 'function') {
|
|
21
|
+
opts = rawArgs[0];
|
|
22
|
+
cb = rawArgs[1];
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
opts = { ...rawArgs[0], ...rawArgs[1] };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
opts = { ...rawArgs[0], ...rawArgs[1] };
|
|
30
|
+
cb = rawArgs[2];
|
|
31
|
+
}
|
|
32
|
+
const spec = responder(opts, callIndex);
|
|
33
|
+
callIndex++;
|
|
34
|
+
recorded.push({ options: opts, spec });
|
|
35
|
+
const req = new EventEmitter();
|
|
36
|
+
let destroyed = false;
|
|
37
|
+
req.setTimeout = (_ms, fn) => {
|
|
38
|
+
if (spec.kind === 'timeout' && fn)
|
|
39
|
+
setTimeout(fn, 0);
|
|
40
|
+
};
|
|
41
|
+
req.destroy = () => { destroyed = true; req.emit('close'); };
|
|
42
|
+
req.end = () => {
|
|
43
|
+
if (spec.kind === 'response' && cb) {
|
|
44
|
+
const res = new EventEmitter();
|
|
45
|
+
res.statusCode = spec.statusCode;
|
|
46
|
+
res.headers = spec.headers;
|
|
47
|
+
let resDestroyed = false;
|
|
48
|
+
res.destroy = () => { resDestroyed = true; };
|
|
49
|
+
setImmediate(() => {
|
|
50
|
+
cb(res);
|
|
51
|
+
setImmediate(() => {
|
|
52
|
+
if (spec.bodyChunks) {
|
|
53
|
+
for (const chunk of spec.bodyChunks) {
|
|
54
|
+
if (resDestroyed)
|
|
55
|
+
break;
|
|
56
|
+
res.emit('data', chunk);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
if (!resDestroyed) {
|
|
61
|
+
res.emit('data', Buffer.isBuffer(spec.body) ? spec.body : Buffer.from(spec.body, 'utf-8'));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!resDestroyed)
|
|
65
|
+
res.emit('end');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else if (spec.kind === 'error') {
|
|
70
|
+
setImmediate(() => req.emit('error', spec.error));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
return req;
|
|
74
|
+
});
|
|
75
|
+
return { recorded };
|
|
76
|
+
}
|
|
77
|
+
const fakeDeps = (addresses) => ({
|
|
78
|
+
resolveAll: async () => addresses,
|
|
79
|
+
});
|
|
80
|
+
const ctx = () => makeCtx(process.cwd());
|
|
81
|
+
const VALID_API_KEY = 'BSA-test-key-1234567890';
|
|
82
|
+
let origKey;
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
origKey = process.env.CSPEACH_BRAVE_SEARCH_KEY;
|
|
85
|
+
process.env.CSPEACH_BRAVE_SEARCH_KEY = VALID_API_KEY;
|
|
86
|
+
vi.clearAllMocks();
|
|
87
|
+
});
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
if (origKey === undefined)
|
|
90
|
+
delete process.env.CSPEACH_BRAVE_SEARCH_KEY;
|
|
91
|
+
else
|
|
92
|
+
process.env.CSPEACH_BRAVE_SEARCH_KEY = origKey;
|
|
93
|
+
vi.clearAllMocks();
|
|
94
|
+
});
|
|
95
|
+
describe('web_search — happy path', () => {
|
|
96
|
+
it('issues a request to BRAVE_ENDPOINT with API key header and parses results', async () => {
|
|
97
|
+
const fakeResponse = {
|
|
98
|
+
web: {
|
|
99
|
+
results: [
|
|
100
|
+
{ title: 'SAP RAP Tutorial', url: 'https://help.sap.com/rap', description: 'Restful Application Programming' },
|
|
101
|
+
{ title: 'CDS Views', url: 'https://help.sap.com/cds', description: 'Core Data Services overview' },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
const { recorded } = setupHttpsMock(() => ({
|
|
106
|
+
kind: 'response',
|
|
107
|
+
statusCode: 200,
|
|
108
|
+
headers: { 'content-type': 'application/json' },
|
|
109
|
+
body: JSON.stringify(fakeResponse),
|
|
110
|
+
}));
|
|
111
|
+
const r = await webSearchHandler({ query: 'sap rap tutorial' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
|
|
112
|
+
expect(r.is_error).toBeFalsy();
|
|
113
|
+
expect(r.content).toContain('SAP RAP Tutorial');
|
|
114
|
+
expect(r.content).toContain('https://help.sap.com/rap');
|
|
115
|
+
expect(r.content).toContain('Restful Application Programming');
|
|
116
|
+
expect(r.content).toContain('CDS Views');
|
|
117
|
+
expect(recorded[0].options.headers?.['X-Subscription-Token']).toBe(VALID_API_KEY);
|
|
118
|
+
expect(recorded[0].options.hostname).toBe('api.search.brave.com');
|
|
119
|
+
});
|
|
120
|
+
it('respects count parameter and caps it at 20', async () => {
|
|
121
|
+
const results = Array.from({ length: 25 }, (_, i) => ({
|
|
122
|
+
title: `Result ${i}`,
|
|
123
|
+
url: `https://example.com/${i}`,
|
|
124
|
+
description: `Description ${i}`,
|
|
125
|
+
}));
|
|
126
|
+
const { recorded } = setupHttpsMock(() => ({
|
|
127
|
+
kind: 'response',
|
|
128
|
+
statusCode: 200,
|
|
129
|
+
headers: { 'content-type': 'application/json' },
|
|
130
|
+
body: JSON.stringify({ web: { results } }),
|
|
131
|
+
}));
|
|
132
|
+
await webSearchHandler({ query: 'test', count: 99 }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
|
|
133
|
+
// The URL the handler builds for Brave must include count=20 (clamped)
|
|
134
|
+
const sentPath = recorded[0].options.path;
|
|
135
|
+
expect(sentPath).toMatch(/count=20/);
|
|
136
|
+
});
|
|
137
|
+
it('returns empty-results envelope cleanly when Brave returns 0 results', async () => {
|
|
138
|
+
setupHttpsMock(() => ({
|
|
139
|
+
kind: 'response',
|
|
140
|
+
statusCode: 200,
|
|
141
|
+
headers: { 'content-type': 'application/json' },
|
|
142
|
+
body: JSON.stringify({ web: { results: [] } }),
|
|
143
|
+
}));
|
|
144
|
+
const r = await webSearchHandler({ query: 'asdfqwerzxcv-zero-results-expected' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
|
|
145
|
+
expect(r.is_error).toBeFalsy();
|
|
146
|
+
expect(r.content).toMatch(/0 results|no results/i);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('web_search — input validation + API key handling', () => {
|
|
150
|
+
it('returns a structured error when CSPEACH_BRAVE_SEARCH_KEY is unset', async () => {
|
|
151
|
+
delete process.env.CSPEACH_BRAVE_SEARCH_KEY;
|
|
152
|
+
const r = await webSearchHandler({ query: 'test' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
|
|
153
|
+
expect(r.is_error).toBe(true);
|
|
154
|
+
expect(r.content).toMatch(/CSPEACH_BRAVE_SEARCH_KEY/);
|
|
155
|
+
expect(r.content).not.toContain(VALID_API_KEY); // sanity — no leak when key IS set but elsewhere referenced
|
|
156
|
+
});
|
|
157
|
+
it('does NOT include the API key value in any error envelope', async () => {
|
|
158
|
+
// Embed the key in the 401 body so the scrub is what makes the assertion
|
|
159
|
+
// pass. Without scrub the key would land in the returned content and
|
|
160
|
+
// the not.toContain would fail. Simulates a misbehaving proxy that
|
|
161
|
+
// echoes the X-Subscription-Token header into its error response body.
|
|
162
|
+
setupHttpsMock(() => ({
|
|
163
|
+
kind: 'response',
|
|
164
|
+
statusCode: 401,
|
|
165
|
+
headers: { 'content-type': 'application/json' },
|
|
166
|
+
body: JSON.stringify({ error: 'invalid api key', debug: VALID_API_KEY }),
|
|
167
|
+
}));
|
|
168
|
+
const r = await webSearchHandler({ query: 'test' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
|
|
169
|
+
expect(r.is_error).toBe(true);
|
|
170
|
+
expect(r.content).not.toContain(VALID_API_KEY);
|
|
171
|
+
// Positive assertion that the scrub fired — the masked marker MUST appear
|
|
172
|
+
// in the returned content (since the mock body contained the key).
|
|
173
|
+
expect(r.content).toContain('***');
|
|
174
|
+
});
|
|
175
|
+
it('rejects empty query with structured error', async () => {
|
|
176
|
+
const r = await webSearchHandler({ query: '' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
|
|
177
|
+
expect(r.is_error).toBe(true);
|
|
178
|
+
expect(r.content).toMatch(/query.*required|empty/i);
|
|
179
|
+
});
|
|
180
|
+
it('rejects whitespace-only query', async () => {
|
|
181
|
+
const r = await webSearchHandler({ query: ' \t\n ' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
|
|
182
|
+
expect(r.is_error).toBe(true);
|
|
183
|
+
expect(r.content).toMatch(/query.*required|empty/i);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe('web_search — error handling', () => {
|
|
187
|
+
it('surfaces a 429 rate-limit response as structured error', async () => {
|
|
188
|
+
setupHttpsMock(() => ({
|
|
189
|
+
kind: 'response',
|
|
190
|
+
statusCode: 429,
|
|
191
|
+
headers: { 'content-type': 'application/json' },
|
|
192
|
+
body: JSON.stringify({ error: 'rate limit exceeded' }),
|
|
193
|
+
}));
|
|
194
|
+
const r = await webSearchHandler({ query: 'test' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
|
|
195
|
+
expect(r.is_error).toBe(true);
|
|
196
|
+
// The handler emits `status 429` (no colon); match the actual format.
|
|
197
|
+
expect(r.content).toMatch(/status 429|rate limit/i);
|
|
198
|
+
});
|
|
199
|
+
it('returns structured error for malformed JSON response', async () => {
|
|
200
|
+
setupHttpsMock(() => ({
|
|
201
|
+
kind: 'response',
|
|
202
|
+
statusCode: 200,
|
|
203
|
+
headers: { 'content-type': 'application/json' },
|
|
204
|
+
body: '{ not valid json',
|
|
205
|
+
}));
|
|
206
|
+
const r = await webSearchHandler({ query: 'test' }, ctx(), { _deps: fakeDeps([{ address: '23.21.34.5', family: 4 }]) });
|
|
207
|
+
expect(r.is_error).toBe(true);
|
|
208
|
+
expect(r.content).toMatch(/json|parse|malformed/i);
|
|
209
|
+
});
|
|
210
|
+
it('rejects when api.search.brave.com resolves to a private IP (DNS-hijack defense)', async () => {
|
|
211
|
+
const r = await webSearchHandler({ query: 'test' }, ctx(), { _deps: fakeDeps([{ address: '127.0.0.1', family: 4 }]) });
|
|
212
|
+
expect(r.is_error).toBe(true);
|
|
213
|
+
expect(r.content).toMatch(/blocked|private|loopback/i);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isWriteTool, WRITE_TOOLS } from '../write-mode.js';
|
|
3
|
+
describe('isWriteTool', () => {
|
|
4
|
+
it('returns true for mutating SAP tools', () => {
|
|
5
|
+
expect(isWriteTool('sap_create_object')).toBe(true);
|
|
6
|
+
expect(isWriteTool('sap_set_source')).toBe(true);
|
|
7
|
+
expect(isWriteTool('sap_update_method')).toBe(true);
|
|
8
|
+
expect(isWriteTool('sap_delete_object')).toBe(true);
|
|
9
|
+
expect(isWriteTool('sap_activate')).toBe(true);
|
|
10
|
+
expect(isWriteTool('sap_message_maintain')).toBe(true);
|
|
11
|
+
expect(isWriteTool('sap_service_binding_publish')).toBe(true);
|
|
12
|
+
expect(isWriteTool('sap_transport_release')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
it('returns false for read-only SAP tools', () => {
|
|
15
|
+
expect(isWriteTool('sap_get_source')).toBe(false);
|
|
16
|
+
expect(isWriteTool('sap_search_object')).toBe(false);
|
|
17
|
+
expect(isWriteTool('sap_atc_run')).toBe(false);
|
|
18
|
+
expect(isWriteTool('sap_syntax_check')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
it('returns false for control tools (lock/unlock/snapshot)', () => {
|
|
21
|
+
expect(isWriteTool('sap_lock')).toBe(false);
|
|
22
|
+
expect(isWriteTool('sap_unlock')).toBe(false);
|
|
23
|
+
expect(isWriteTool('sap_snapshot')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it('returns false for unknown tool names', () => {
|
|
26
|
+
expect(isWriteTool('unknown_tool')).toBe(false);
|
|
27
|
+
expect(isWriteTool('')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
it('WRITE_TOOLS is non-empty and contains the canonical eight', () => {
|
|
30
|
+
expect(WRITE_TOOLS.size).toBe(8);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { formatLoginBanner } from '../login-banner.js';
|
|
3
|
+
const base = {
|
|
4
|
+
email: 'a@b.com', company: null, plan: 'solo',
|
|
5
|
+
trial_mode: null, trial_ends_at: null,
|
|
6
|
+
trial_token_cap_input: null, trial_token_cap_output: null,
|
|
7
|
+
usage_input_tokens: 0, usage_output_tokens: 0,
|
|
8
|
+
entitlements: { enabled_skills: [], agent_mode: 'skill', enabled_features: [] },
|
|
9
|
+
};
|
|
10
|
+
describe('formatLoginBanner', () => {
|
|
11
|
+
it('non-trial: just shows email + plan', () => {
|
|
12
|
+
const { line, warningLine } = formatLoginBanner({ ...base, plan: 'solo' });
|
|
13
|
+
expect(line).toMatch(/Signed in as a@b\.com/);
|
|
14
|
+
expect(line).toMatch(/solo/);
|
|
15
|
+
expect(warningLine).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
it('managed-capped trial: shows days left + token progress', () => {
|
|
18
|
+
// +1 h buffer so floor division stays at 6 even if a few ms elapse between
|
|
19
|
+
// test setup and banner render.
|
|
20
|
+
const ends = new Date(Date.now() + 6 * 86400_000 + 3600_000).toISOString();
|
|
21
|
+
const { line } = formatLoginBanner({
|
|
22
|
+
...base, plan: 'trial-managed', trial_mode: 'managed-capped',
|
|
23
|
+
trial_ends_at: ends, trial_token_cap_input: 200_000, trial_token_cap_output: 50_000,
|
|
24
|
+
usage_input_tokens: 47_000, usage_output_tokens: 8_000,
|
|
25
|
+
});
|
|
26
|
+
expect(line).toMatch(/trial/i);
|
|
27
|
+
expect(line).toMatch(/6\s*days?/i);
|
|
28
|
+
expect(line).toMatch(/47/);
|
|
29
|
+
expect(line).toMatch(/200/);
|
|
30
|
+
});
|
|
31
|
+
it('byok trial: omits token budget (customer pays Anthropic directly)', () => {
|
|
32
|
+
const ends = new Date(Date.now() + 6 * 86400_000).toISOString();
|
|
33
|
+
const { line } = formatLoginBanner({
|
|
34
|
+
...base, plan: 'trial-byok', trial_mode: 'byok',
|
|
35
|
+
trial_ends_at: ends,
|
|
36
|
+
});
|
|
37
|
+
expect(line).toMatch(/byok/i);
|
|
38
|
+
expect(line).toMatch(/6\s*days?/i);
|
|
39
|
+
expect(line).not.toMatch(/\/200/);
|
|
40
|
+
});
|
|
41
|
+
it('warning fires when token usage ≥ 80% of input cap', () => {
|
|
42
|
+
const ends = new Date(Date.now() + 6 * 86400_000).toISOString();
|
|
43
|
+
const { warningLine } = formatLoginBanner({
|
|
44
|
+
...base, plan: 'trial-managed', trial_mode: 'managed-capped',
|
|
45
|
+
trial_ends_at: ends, trial_token_cap_input: 100_000, trial_token_cap_output: 25_000,
|
|
46
|
+
usage_input_tokens: 82_000, usage_output_tokens: 5_000,
|
|
47
|
+
});
|
|
48
|
+
expect(warningLine).not.toBeNull();
|
|
49
|
+
expect(warningLine).toMatch(/80%|budget|cap/i);
|
|
50
|
+
});
|
|
51
|
+
it('warning fires when trial expiry < 24 hours away', () => {
|
|
52
|
+
const ends = new Date(Date.now() + 12 * 3600_000).toISOString();
|
|
53
|
+
const { warningLine } = formatLoginBanner({
|
|
54
|
+
...base, plan: 'trial-managed', trial_mode: 'managed-capped',
|
|
55
|
+
trial_ends_at: ends, trial_token_cap_input: 200_000, trial_token_cap_output: 50_000,
|
|
56
|
+
});
|
|
57
|
+
expect(warningLine).not.toBeNull();
|
|
58
|
+
expect(warningLine).toMatch(/expires|24 hours|hours? left/i);
|
|
59
|
+
});
|
|
60
|
+
it('expired trial: warning says expired', () => {
|
|
61
|
+
const ends = new Date(Date.now() - 86400_000).toISOString();
|
|
62
|
+
const { warningLine } = formatLoginBanner({
|
|
63
|
+
...base, plan: 'trial-managed', trial_mode: 'managed-capped',
|
|
64
|
+
trial_ends_at: ends, trial_token_cap_input: 200_000, trial_token_cap_output: 50_000,
|
|
65
|
+
});
|
|
66
|
+
expect(warningLine).not.toBeNull();
|
|
67
|
+
expect(warningLine).toMatch(/expired/i);
|
|
68
|
+
});
|
|
69
|
+
});
|