sap-ai-dev-toolkit 0.5.0 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/abap-developer.agent.md +3 -3
- package/.github/agents/abap-runtime-debugger.agent.md +3 -3
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/agents/sap-solution-architect.agent.md +1 -1
- package/.github/skills/abap-debugging/SKILL.md +3 -3
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +3 -3
- package/.github/skills/abap-testing-quality/SKILL.md +1 -1
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
- package/.github/skills/hana-cloud-native-development/SKILL.md +2 -2
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +1 -1
- package/.github/skills/rap-service-delivery/SKILL.md +1 -1
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +1 -1
- package/README.md +17 -11
- package/inventory.md +179 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +17 -18
- package/src/bas-destination-relay.mjs +58 -22
- package/src/bas-discovery.mjs +24 -8
- package/src/binary.mjs +101 -17
- package/src/cf-destination.mjs +55 -1
- package/src/credentials-store.mjs +0 -10
- package/src/engineering-tools.mjs +32 -10
- package/src/hana-tools.mjs +9 -2
- package/src/launcher.mjs +22 -19
- package/src/mcp-config.mjs +41 -7
- package/src/mcp-proxy.mjs +392 -104
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +33 -3
- package/tools.md +4 -2
- package/test/bas-destination-relay.test.mjs +0 -374
- package/test/cf-connectivity.test.mjs +0 -287
- package/test/cf-destination.test.mjs +0 -307
- package/test/cf-runtime.test.mjs +0 -527
- package/test/copilot-assets.test.mjs +0 -63
- package/test/copilot-content.test.mjs +0 -249
- package/test/credentials-store.test.mjs +0 -187
- package/test/discovery.test.mjs +0 -257
- package/test/fixtures/fake-vsp.mjs +0 -263
- package/test/hana-config.test.mjs +0 -168
- package/test/hana-inspector-stdio.test.mjs +0 -44
- package/test/hana-tools.test.mjs +0 -206
- package/test/launcher.test.mjs +0 -399
- package/test/live-s4h.test.mjs +0 -202
- package/test/mcp-config-cf.test.mjs +0 -407
- package/test/mcp-proxy.test.mjs +0 -409
- package/test/pty.mjs +0 -8
- package/test/setup-cf.test.mjs +0 -489
- package/test/setup.test.mjs +0 -417
- package/test/terminal-ui.test.mjs +0 -47
package/src/redact.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared credential redaction for every diagnostic lane (stderr, MCP log
|
|
2
|
+
// notifications, tool errors, relay responses). One implementation so a fix
|
|
3
|
+
// applies everywhere; the old per-file copies each missed a different case.
|
|
4
|
+
//
|
|
5
|
+
// Covered patterns:
|
|
6
|
+
// - authorization: Basic <base64> / Bearer <token> / proxy-authorization
|
|
7
|
+
// - cookie: a=b; c=d (the whole pair list, not just the first pair)
|
|
8
|
+
// - password=... / secret=... / token=... / x-csrf-token: ... style keys
|
|
9
|
+
// - URLs with embedded userinfo, https://user:pass@host
|
|
10
|
+
// Bare credential blobs without a recognizable key cannot be detected
|
|
11
|
+
// generically and are out of scope.
|
|
12
|
+
const URL_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)([^\s/@:?=&]+):([^\s/@]+)@/gi;
|
|
13
|
+
const NAMED_VALUE = /((?:proxy-)?authorization|cookie|passwd|password|secret|token)\s*[:=]\s*(?:bearer\s+|basic\s+)?[^\s,;"']+/gi;
|
|
14
|
+
// Header-shaped values may carry lists (cookie pairs); after the first pass
|
|
15
|
+
// redacts the leading pair, this pass drops the remainder of the line.
|
|
16
|
+
const HEADER_TAIL = /^((?:proxy-)?authorization|cookie)\s*[:=].*$/gim;
|
|
17
|
+
|
|
18
|
+
export function redactText(value) {
|
|
19
|
+
return String(value ?? '')
|
|
20
|
+
.replace(URL_USERINFO, '$1[redacted]@')
|
|
21
|
+
.replace(NAMED_VALUE, '$1=[redacted]')
|
|
22
|
+
.replace(HEADER_TAIL, '$1=[redacted]');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Redact, collapse whitespace, and clamp to a diagnostic-friendly length.
|
|
26
|
+
export function diagnosticText(value, limit = 500) {
|
|
27
|
+
return redactText(value?.message || value || 'unknown error').replace(/\s+/g, ' ').trim().slice(0, limit);
|
|
28
|
+
}
|
package/src/setup.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
3
|
import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
|
|
4
4
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
5
|
-
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
5
|
+
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
6
6
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
7
7
|
import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from './credentials-store.mjs';
|
|
8
8
|
import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from './credential-overrides.mjs';
|
|
@@ -167,8 +167,9 @@ function printConnectionInstructions(output, result) {
|
|
|
167
167
|
print(output, '');
|
|
168
168
|
print(output, colorText('📡 MCP servers now available:', 'cyan', output));
|
|
169
169
|
for (const [name, entry] of servers) {
|
|
170
|
-
const
|
|
171
|
-
|
|
170
|
+
const destinationName = entry.env?.SAP_AI_DEV_TOOLKIT_DESTINATION || entry.env?.BAS_VSP_DESTINATION;
|
|
171
|
+
const detail = destinationName
|
|
172
|
+
? `destination: ${destinationName}`
|
|
172
173
|
: (entry.displayName ? `tool server: ${entry.displayName}` : 'tool server');
|
|
173
174
|
print(output, ` • ${name} — ${detail}`);
|
|
174
175
|
}
|
|
@@ -223,6 +224,35 @@ export async function runSetup({
|
|
|
223
224
|
if (!target.available) {
|
|
224
225
|
warnings.push(target.reason);
|
|
225
226
|
} else {
|
|
227
|
+
// Sweep for service keys this toolkit created but no config references
|
|
228
|
+
// (an interrupted earlier setup leaves them behind; a Destination key
|
|
229
|
+
// exposes credentials for every destination in its instance).
|
|
230
|
+
try {
|
|
231
|
+
const orphans = await findOrphanedCloudFoundryServiceKeys({ env, spaceGuid: target.spaceGuid, managedKeys });
|
|
232
|
+
if (orphans.length) {
|
|
233
|
+
print(output, '');
|
|
234
|
+
print(output, formatStatus(`Found ${orphans.length} unreferenced sap-ai-dev-toolkit service key${orphans.length === 1 ? '' : 's'} in this CF space:`, 'warning', output, 'Cloud Foundry'));
|
|
235
|
+
for (const orphan of orphans.slice(0, 5)) print(output, ` • ${orphan.keyName} on ${orphan.instanceName}`);
|
|
236
|
+
const readline = createInterface({ input, output });
|
|
237
|
+
let removeOrphans = false;
|
|
238
|
+
try {
|
|
239
|
+
const answer = await readline.question('Delete these unreferenced service keys? (y + Enter = delete; Enter = keep) ');
|
|
240
|
+
removeOrphans = /^y(?:es)?$/i.test(answer.trim());
|
|
241
|
+
} finally {
|
|
242
|
+
readline.close();
|
|
243
|
+
}
|
|
244
|
+
if (removeOrphans) {
|
|
245
|
+
const result = await deleteManagedCloudFoundryServiceKeys({ env, keys: orphans });
|
|
246
|
+
warnings.push(...(result.warnings || []));
|
|
247
|
+
const deleted = orphans.length - (result.warnings || []).length;
|
|
248
|
+
print(output, formatStatus(`Removed ${deleted} unreferenced service key${deleted === 1 ? '' : 's'}.`, 'success', output, 'Cloud Foundry'));
|
|
249
|
+
} else {
|
|
250
|
+
warnings.push(`${orphans.length} unreferenced sap-ai-dev-toolkit service key${orphans.length === 1 ? '' : 's'} were kept; review them with "cf service-keys" and delete manually if unwanted.`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// The sweep is best-effort; a failed lookup must not block setup.
|
|
255
|
+
}
|
|
226
256
|
let shouldImport = false;
|
|
227
257
|
try {
|
|
228
258
|
shouldImport = await confirmCfImport({ input, output });
|
package/tools.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
The proxy no longer advertises every VSP tool. To keep the developer-lifecycle MCP surface manageable, each generated one-destination server exposes a cherry-picked set of VSP tools plus local workflow tools (currently 59 tools when all curated VSP capabilities are registered).
|
|
4
4
|
|
|
5
|
+
The names below are the logical tool names. Publicly, every tool is exposed as `<destination-slug>_<tool>` in lowercase (for example `GetTableContents` on destination `DEMO_ABAP` is `demo-abap_get_table_contents`): BAS/VS Code chat tool references bind only lowercase identifiers, so mixed-case names never reach the model.
|
|
6
|
+
|
|
5
7
|
Hidden upstream VSP tools are not directly callable through the proxy. Local workflow tools may still call hidden or non-advertised upstream operations internally when they are required for a bounded workflow.
|
|
6
8
|
|
|
7
9
|
## Public VSP tools
|
|
@@ -64,7 +66,7 @@ Broad/destructive surfaces such as object deletion, SQL trace, arbitrary SAP rou
|
|
|
64
66
|
|
|
65
67
|
## Destination-scoped workflow tools
|
|
66
68
|
|
|
67
|
-
The proxy adds local multi-step tools to each destination when their required VSP capabilities are available:
|
|
69
|
+
The proxy adds local multi-step tools to each destination when their required VSP capabilities are available. Like the VSP tools above, they are exposed publicly in lowercase snake_case (`lint_abap`, `get_application_log`, `prepare_abap_change_set`, and so on):
|
|
68
70
|
|
|
69
71
|
- `LintABAP` — local in-memory ABAP linting.
|
|
70
72
|
- `GetApplicationLog` — bounded convenience mapping to the upstream SAP application-log route when available.
|
|
@@ -73,7 +75,7 @@ The proxy adds local multi-step tools to each destination when their required VS
|
|
|
73
75
|
- `PlanABAPCloudMigration` — batch SAP API release-state checks and prioritize recognized unreleased results.
|
|
74
76
|
- `GenerateRAPRegressionSuite` and `RunRAPRegressionSuite` — create and run reusable OData GET-only checks under the selected service root.
|
|
75
77
|
|
|
76
|
-
`sap-ai-dev-toolkit --doctor` checks destination probing, VSP startup, `
|
|
78
|
+
`sap-ai-dev-toolkit --doctor` checks destination probing, VSP startup, the `get_system_info` probe, and MCP tool listing. `sap-ai-dev-toolkit --demo` runs the sample tools and OData fixtures without contacting SAP; demo writes and transport creation remain in memory until that process exits.
|
|
77
79
|
|
|
78
80
|
## Separate optional HANA Cloud inspector
|
|
79
81
|
|
|
@@ -1,374 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import http from 'node:http';
|
|
4
|
-
import net from 'node:net';
|
|
5
|
-
import { request as httpRequest } from 'node:http';
|
|
6
|
-
import { createBasDestinationRelay } from '../src/bas-destination-relay.mjs';
|
|
7
|
-
|
|
8
|
-
const UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
9
|
-
|
|
10
|
-
// Simulates SAP ADT semantics: a CSRF token is only valid when the unsafe
|
|
11
|
-
// request carries BOTH the token and the session cookies that were set
|
|
12
|
-
// together with it. This reproduces the 403 "CSRF token validation failed"
|
|
13
|
-
// seen on the BAS .dest proxy path before the relay paired sessions.
|
|
14
|
-
function sapLikeBackend({
|
|
15
|
-
neverAcceptUnsafe = false,
|
|
16
|
-
rotateOnFetch = false,
|
|
17
|
-
genericForbidden = false,
|
|
18
|
-
partialRefreshCookies = false,
|
|
19
|
-
omitCookies = false,
|
|
20
|
-
setCookieOnSafeGet = false,
|
|
21
|
-
secureSessionCookie = false
|
|
22
|
-
} = {}) {
|
|
23
|
-
const state = { sessionCounter: 0, requests: [] };
|
|
24
|
-
const server = http.createServer((req, res) => {
|
|
25
|
-
state.requests.push({ method: req.method, path: req.url, headers: { ...req.headers } });
|
|
26
|
-
if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
|
|
27
|
-
state.sessionCounter += 1;
|
|
28
|
-
const headers = { 'x-csrf-token': `token-${state.sessionCounter}` };
|
|
29
|
-
if (!omitCookies) {
|
|
30
|
-
headers['set-cookie'] = [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`];
|
|
31
|
-
if (!partialRefreshCookies || state.sessionCounter === 1) {
|
|
32
|
-
headers['set-cookie'].push('sap-usercontext=sap-client=100; path=/');
|
|
33
|
-
}
|
|
34
|
-
if (partialRefreshCookies) {
|
|
35
|
-
headers['set-cookie'].push(state.sessionCounter === 1
|
|
36
|
-
? 'sap-stale=delete-me; path=/'
|
|
37
|
-
: 'sap-stale=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/');
|
|
38
|
-
}
|
|
39
|
-
if (secureSessionCookie) headers['set-cookie'].push('sap-secure=secure-value; Secure; Path=/');
|
|
40
|
-
}
|
|
41
|
-
res.writeHead(200, headers);
|
|
42
|
-
res.end('<ok/>');
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
if (req.method === 'GET' && req.url === '/sap/bc/adt/discovery' && setCookieOnSafeGet) {
|
|
46
|
-
res.writeHead(200, { 'set-cookie': 'bas-read-session=kept; path=/sap/bc/adt' });
|
|
47
|
-
res.end('<read/>');
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
if (UNSAFE_METHODS.includes(req.method)) {
|
|
51
|
-
if (genericForbidden) {
|
|
52
|
-
res.writeHead(403);
|
|
53
|
-
res.end('User is not authorized to change this object');
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
if (neverAcceptUnsafe) {
|
|
57
|
-
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
58
|
-
res.end('CSRF token validation failed');
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
const token = req.headers['x-csrf-token'];
|
|
62
|
-
const cookie = String(req.headers.cookie || '');
|
|
63
|
-
const match = String(token || '').match(/^token-(\d+)$/);
|
|
64
|
-
// Without rotation any still-live token+cookie pair is accepted; with
|
|
65
|
-
// rotation only the newest issued session remains valid, matching SAP
|
|
66
|
-
// server-side session invalidation.
|
|
67
|
-
const valid = match
|
|
68
|
-
&& cookie.includes(`sap-session-${match[1]}=`)
|
|
69
|
-
&& (!rotateOnFetch || Number(match[1]) === state.sessionCounter)
|
|
70
|
-
&& (!partialRefreshCookies || cookie.includes('sap-usercontext=sap-client=100'))
|
|
71
|
-
&& (!partialRefreshCookies || Number(match[1]) === 1 || !cookie.includes('sap-stale='));
|
|
72
|
-
if (!valid) {
|
|
73
|
-
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
74
|
-
res.end('CSRF token validation failed');
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
res.writeHead(201);
|
|
78
|
-
res.end('<created/>');
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
res.writeHead(200);
|
|
82
|
-
res.end('<read/>');
|
|
83
|
-
});
|
|
84
|
-
return { state, server };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function startServer(server) {
|
|
88
|
-
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
89
|
-
return server.address().port;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function relayFetch(relayUrl, path, options = {}) {
|
|
93
|
-
const target = new URL(path, relayUrl);
|
|
94
|
-
return new Promise((resolve, reject) => {
|
|
95
|
-
const req = httpRequest(target, { method: options.method || 'GET', headers: options.headers || {} }, response => {
|
|
96
|
-
let body = '';
|
|
97
|
-
response.setEncoding('utf8');
|
|
98
|
-
response.on('data', chunk => { body += chunk; });
|
|
99
|
-
response.on('end', () => resolve({ status: response.statusCode, headers: response.headers, body }));
|
|
100
|
-
});
|
|
101
|
-
req.on('error', reject);
|
|
102
|
-
if (options.body) req.write(options.body);
|
|
103
|
-
req.end();
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function withBackend(options, handler) {
|
|
108
|
-
const { state, server } = sapLikeBackend(options || {});
|
|
109
|
-
const backendPort = await startServer(server);
|
|
110
|
-
const logs = [];
|
|
111
|
-
const relay = createBasDestinationRelay(
|
|
112
|
-
{ name: 'TEST', url: `http://127.0.0.1:${backendPort}`, authentication: 'BasicAuthentication' },
|
|
113
|
-
{
|
|
114
|
-
env: { HTTP_PROXY: '', ...(options?.env || {}) },
|
|
115
|
-
fetchImpl: options?.fetchImpl || ((url, init) => globalThis.fetch(url, init)),
|
|
116
|
-
log: message => logs.push(message)
|
|
117
|
-
}
|
|
118
|
-
);
|
|
119
|
-
const relayUrl = await relay.ready;
|
|
120
|
-
try {
|
|
121
|
-
return await handler({ state, relay, relayUrl, logs });
|
|
122
|
-
} finally {
|
|
123
|
-
await relay.close();
|
|
124
|
-
await new Promise(resolve => server.close(resolve));
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
test('pairs CSRF token with its session cookies so unsafe requests succeed', async () => {
|
|
129
|
-
await withBackend(null, async ({ relayUrl, state }) => {
|
|
130
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', headers: { accept: 'application/xml' }, body: '<x/>' });
|
|
131
|
-
assert.equal(response.status, 201, `expected 201, got ${response.status}: ${response.body}`);
|
|
132
|
-
const post = state.requests.find(request => request.method === 'POST');
|
|
133
|
-
assert.match(post.headers['x-csrf-token'], /^token-\d+$/);
|
|
134
|
-
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test('reuses the cached CSRF session for subsequent unsafe requests', async () => {
|
|
139
|
-
await withBackend(null, async ({ relay, relayUrl, state }) => {
|
|
140
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
141
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
|
|
142
|
-
const fetches = state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
143
|
-
assert.equal(fetches.length, 1, 'second POST must reuse the cached session');
|
|
144
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 2);
|
|
145
|
-
assert.equal(relay.stats.csrfFetches, 1);
|
|
146
|
-
assert.equal(relay.stats.csrfRetries, 0);
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test('coalesces concurrent CSRF token fetches for the same ADT endpoint', async () => {
|
|
151
|
-
await withBackend(null, async ({ relayUrl, state }) => {
|
|
152
|
-
const responses = await Promise.all([
|
|
153
|
-
relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' }),
|
|
154
|
-
relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' })
|
|
155
|
-
]);
|
|
156
|
-
assert.deepEqual(responses.map(response => response.status), [201, 201]);
|
|
157
|
-
assert.equal(state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch').length, 1);
|
|
158
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 2);
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test('seeds the relay cookie jar from the incoming MCP request cookie', async () => {
|
|
163
|
-
await withBackend(null, async ({ relayUrl, state }) => {
|
|
164
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', {
|
|
165
|
-
method: 'POST',
|
|
166
|
-
headers: { cookie: 'sap-initial-session=from-vsp' },
|
|
167
|
-
body: '<x/>'
|
|
168
|
-
});
|
|
169
|
-
const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
170
|
-
assert.match(String(tokenFetch.headers.cookie), /sap-initial-session=from-vsp/);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test('self-heals when SAP rotates the session: refetches token and cookies', async () => {
|
|
175
|
-
await withBackend({ rotateOnFetch: true }, async ({ relay, relayUrl, state }) => {
|
|
176
|
-
// A first POST establishes session token-1. SAP then invalidates it
|
|
177
|
-
// server-side (simulated by the rotation rule below).
|
|
178
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
179
|
-
state.sessionCounter += 1;
|
|
180
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
|
|
181
|
-
assert.equal(response.status, 201, `rotated session must heal via refetch, got ${response.status}`);
|
|
182
|
-
assert.ok(relay.stats.csrfRetries >= 1, 'a retry must have been counted');
|
|
183
|
-
const fetches = state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
184
|
-
assert.ok(fetches.length >= 2, 'a fresh token fetch must have occurred');
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test('merges partial session-cookie updates and applies cookie deletion on refresh', async () => {
|
|
189
|
-
await withBackend({ rotateOnFetch: true, partialRefreshCookies: true }, async ({ relayUrl, state }) => {
|
|
190
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
191
|
-
state.sessionCounter += 1;
|
|
192
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
|
|
193
|
-
assert.equal(response.status, 201, `partial cookie refresh must heal, got ${response.status}`);
|
|
194
|
-
const posts = state.requests.filter(request => request.method === 'POST');
|
|
195
|
-
assert.match(String(posts[2].headers.cookie), /sap-usercontext=sap-client=100/);
|
|
196
|
-
assert.doesNotMatch(String(posts[2].headers.cookie), /sap-stale=/);
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test('retains cookies received on a safe GET for later CSRF requests', async () => {
|
|
201
|
-
await withBackend({ setCookieOnSafeGet: true }, async ({ relayUrl, state }) => {
|
|
202
|
-
await relayFetch(relayUrl, '/sap/bc/adt/discovery');
|
|
203
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
204
|
-
assert.equal(response.status, 201);
|
|
205
|
-
const tokenFetch = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
206
|
-
assert.match(String(tokenFetch.headers.cookie), /bas-read-session=kept/);
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test('does not retry generic authorization failures as CSRF errors', async () => {
|
|
211
|
-
await withBackend({ genericForbidden: true }, async ({ relayUrl, relay, state }) => {
|
|
212
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
213
|
-
assert.equal(response.status, 403);
|
|
214
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 1);
|
|
215
|
-
assert.equal(relay.stats.csrfRetries, 0);
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test('does not repeat a CSRF failure when SAP returns a token without session cookies', async () => {
|
|
220
|
-
await withBackend({ omitCookies: true }, async ({ relayUrl, relay, state }) => {
|
|
221
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
222
|
-
assert.equal(response.status, 503, `unsafe calls must fail closed without a paired session, got ${response.status}`);
|
|
223
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 0);
|
|
224
|
-
assert.equal(relay.stats.csrfRetries, 0);
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test('stops retrying after bounded attempts and surfaces the failure', async () => {
|
|
229
|
-
await withBackend({ neverAcceptUnsafe: true }, async ({ relay, relayUrl, state }) => {
|
|
230
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
231
|
-
assert.equal(response.status, 403);
|
|
232
|
-
const attempts = state.requests.filter(request => UNSAFE_METHODS.includes(request.method));
|
|
233
|
-
assert.equal(attempts.length, 1 + 3, 'one initial try plus three bounded retries');
|
|
234
|
-
assert.equal(relay.stats.csrfRetries, 3);
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
test('honors the configured bounded CSRF retry count', async () => {
|
|
239
|
-
await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '1' } }, async ({ relayUrl, relay, state }) => {
|
|
240
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
241
|
-
assert.equal(response.status, 403);
|
|
242
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 1 + 1);
|
|
243
|
-
assert.equal(relay.stats.csrfRetries, 1);
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test('uses the default for invalid retry settings and caps excessive retry counts', async () => {
|
|
248
|
-
await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '1.5' } }, async ({ relayUrl, state }) => {
|
|
249
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
250
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 4, 'invalid values use the default of three retries');
|
|
251
|
-
});
|
|
252
|
-
await withBackend({ neverAcceptUnsafe: true, env: { SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES: '100' } }, async ({ relayUrl, state }) => {
|
|
253
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
254
|
-
assert.equal(state.requests.filter(request => request.method === 'POST').length, 11, 'retry counts are capped at ten');
|
|
255
|
-
});
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
test('prefers the documented branded proxy setting including an explicit direct-connection override', async t => {
|
|
259
|
-
let dispatchedWithProxy = false;
|
|
260
|
-
const relay = createBasDestinationRelay(
|
|
261
|
-
{ name: 'TEST', url: 'http://test.dest', authentication: 'BasicAuthentication' },
|
|
262
|
-
{
|
|
263
|
-
env: { HTTP_PROXY: 'http://127.0.0.1:1', SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '' },
|
|
264
|
-
fetchImpl: async (_url, options = {}) => {
|
|
265
|
-
dispatchedWithProxy = Boolean(options.dispatcher);
|
|
266
|
-
return new Response('<ok/>', { status: 200, headers: { 'x-csrf-token': 'token', 'set-cookie': 'sap-session=s; path=/' } });
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
);
|
|
270
|
-
t.after(() => relay.close());
|
|
271
|
-
await relay.ready;
|
|
272
|
-
const probe = await relay.probeCsrfSession();
|
|
273
|
-
assert.equal(probe.sessionUsable, true);
|
|
274
|
-
assert.equal(dispatchedWithProxy, false, 'an explicit empty branded proxy setting means direct traffic');
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
test('GET requests pass through without CSRF handling', async () => {
|
|
278
|
-
await withBackend(null, async ({ relayUrl, state, relay }) => {
|
|
279
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/discovery', { headers: { accept: 'application/xml' } });
|
|
280
|
-
assert.equal(response.status, 200);
|
|
281
|
-
assert.equal(state.requests.filter(request => request.headers['x-csrf-token']).length, 0);
|
|
282
|
-
assert.equal(relay.stats.csrfFetches, 0);
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
test('drops POST body headers from the token-fetch GET', async () => {
|
|
287
|
-
await withBackend(null, async ({ relayUrl, state }) => {
|
|
288
|
-
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', {
|
|
289
|
-
method: 'POST',
|
|
290
|
-
headers: { accept: 'application/xml', 'content-type': 'application/xml', 'content-length': '5' },
|
|
291
|
-
body: '<xy/>'
|
|
292
|
-
});
|
|
293
|
-
const fetchRequest = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
294
|
-
assert.equal(fetchRequest.headers['content-type'], undefined, 'content-type must not leak into token GET');
|
|
295
|
-
assert.equal(fetchRequest.headers['content-length'], undefined, 'content-length must not leak into token GET');
|
|
296
|
-
});
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
test('falls back to a proxy tunnel when the absolute-form request is refused', async () => {
|
|
300
|
-
const fetchCalls = [];
|
|
301
|
-
const fetchImpl = (url, options = {}) => {
|
|
302
|
-
fetchCalls.push({ url: String(url), tunneled: Boolean(options.dispatcher && options.dispatcher.proxyTunnel !== false && options.dispatcher.constructor?.name === 'ProxyAgent' && options.dispatcher.__tunnelMarker !== false) });
|
|
303
|
-
// First call (absolute-form through proxy) is refused by the proxy itself.
|
|
304
|
-
if (fetchCalls.length === 1) return Promise.resolve(new Response('<gateway/>', { status: 502 }));
|
|
305
|
-
return Promise.resolve(new Response('<ok/>', { status: 200, headers: { 'x-csrf-token': 'tok' } }));
|
|
306
|
-
};
|
|
307
|
-
const { state, server } = sapLikeBackend();
|
|
308
|
-
void state;
|
|
309
|
-
const backendPort = await startServer(server);
|
|
310
|
-
const relay = createBasDestinationRelay(
|
|
311
|
-
{ name: 'TEST', url: `http://127.0.0.1:${backendPort}`, authentication: 'BasicAuthentication' },
|
|
312
|
-
{ env: { HTTP_PROXY: 'http://127.0.0.1:1' }, fetchImpl, log: () => {} }
|
|
313
|
-
);
|
|
314
|
-
const relayUrl = await relay.ready;
|
|
315
|
-
try {
|
|
316
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/discovery');
|
|
317
|
-
assert.equal(response.status, 200);
|
|
318
|
-
assert.equal(relay.stats.tunnelFallbacks, 1, 'exactly one tunnel fallback must occur');
|
|
319
|
-
assert.equal(fetchCalls.length, 2, 'absolute-form attempt then one tunnel retry');
|
|
320
|
-
} finally {
|
|
321
|
-
await relay.close();
|
|
322
|
-
await new Promise(resolve => server.close(resolve));
|
|
323
|
-
}
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF session', async t => {
|
|
327
|
-
const { state, server } = sapLikeBackend({ secureSessionCookie: true });
|
|
328
|
-
const backendPort = await startServer(server);
|
|
329
|
-
const connectTargets = [];
|
|
330
|
-
const proxy = http.createServer();
|
|
331
|
-
proxy.on('connect', (request, clientSocket, head) => {
|
|
332
|
-
connectTargets.push(request.url);
|
|
333
|
-
const upstream = net.connect(backendPort, '127.0.0.1', () => {
|
|
334
|
-
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
335
|
-
if (head.length) upstream.write(head);
|
|
336
|
-
clientSocket.pipe(upstream);
|
|
337
|
-
upstream.pipe(clientSocket);
|
|
338
|
-
});
|
|
339
|
-
clientSocket.on('error', () => upstream.destroy());
|
|
340
|
-
upstream.on('error', () => clientSocket.destroy());
|
|
341
|
-
});
|
|
342
|
-
const proxyPort = await startServer(proxy);
|
|
343
|
-
const logs = [];
|
|
344
|
-
const relay = createBasDestinationRelay({
|
|
345
|
-
name: 'CC-SYSTEM',
|
|
346
|
-
url: 'http://cc-system.dest',
|
|
347
|
-
authentication: 'BasicAuthentication',
|
|
348
|
-
credentials: { user: 'DEVELOPER', password: 'secret123', mode: 'bas-tunnel' }
|
|
349
|
-
}, {
|
|
350
|
-
env: { HTTP_PROXY: `http://127.0.0.1:${proxyPort}` },
|
|
351
|
-
log: message => logs.push(message)
|
|
352
|
-
});
|
|
353
|
-
const relayUrl = await relay.ready;
|
|
354
|
-
t.after(async () => {
|
|
355
|
-
await relay.close();
|
|
356
|
-
proxy.closeAllConnections?.();
|
|
357
|
-
await new Promise(resolve => proxy.close(resolve));
|
|
358
|
-
await new Promise(resolve => server.close(resolve));
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
const probe = await relay.probeCsrfSession();
|
|
362
|
-
assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 3, sessionUsable: true });
|
|
363
|
-
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
364
|
-
assert.equal(response.status, 201, `expected a paired CSRF session, got ${response.status}: ${response.body}`);
|
|
365
|
-
const post = state.requests.find(request => request.method === 'POST');
|
|
366
|
-
assert.match(post.headers.authorization, /^Basic /);
|
|
367
|
-
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
368
|
-
assert.match(String(post.headers.cookie), /sap-secure=secure-value/, 'Secure SAP cookies must be sent through the HTTPS-backed BAS .dest route');
|
|
369
|
-
assert.ok(connectTargets.length >= 2);
|
|
370
|
-
assert.ok(connectTargets.every(target => target === 'cc-system.dest:80'));
|
|
371
|
-
assert.equal(relay.stats.direct, false);
|
|
372
|
-
assert.equal(relay.stats.proxyTunnel, true);
|
|
373
|
-
assert.ok(logs.some(message => message.includes('BAS proxy tunnel')));
|
|
374
|
-
});
|