sap-ai-dev-toolkit 0.5.5 → 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 +6 -6
- package/inventory.md +179 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +14 -16
- package/src/bas-destination-relay.mjs +25 -3
- package/src/bas-discovery.mjs +9 -3
- 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 +8 -11
- package/src/mcp-config.mjs +19 -1
- package/src/mcp-proxy.mjs +230 -76
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +30 -1
- package/tools.md +1 -1
- package/test/bas-destination-relay.test.mjs +0 -415
- 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 -282
- package/test/fixtures/fake-vsp.mjs +0 -266
- 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 -401
- package/test/live-s4h.test.mjs +0 -204
- package/test/mcp-config-cf.test.mjs +0 -475
- package/test/mcp-proxy.test.mjs +0 -604
- 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
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { quoteHanaIdentifier, resolveHanaReadOnlyConfig } from '../src/hana-config.mjs';
|
|
4
|
-
import { hanaConnectionOptions, HanaDatabase } from '../src/hana-database.mjs';
|
|
5
|
-
|
|
6
|
-
const explicitEnv = {
|
|
7
|
-
HANA_RO_HOST: 'hana.example.test',
|
|
8
|
-
HANA_RO_PORT: '443',
|
|
9
|
-
HANA_RO_USER: 'INSPECTOR',
|
|
10
|
-
HANA_RO_PASSWORD: 'do-not-print-this',
|
|
11
|
-
HANA_RO_SCHEMA: 'HDI_CONTAINER_SCHEMA'
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
function vcapBinding(name, user = 'INSPECTOR') {
|
|
15
|
-
return {
|
|
16
|
-
name,
|
|
17
|
-
binding_name: name,
|
|
18
|
-
credentials: {
|
|
19
|
-
host: 'hana.example.test',
|
|
20
|
-
port: 443,
|
|
21
|
-
user,
|
|
22
|
-
password: 'vcap-secret-not-for-output',
|
|
23
|
-
schema: 'HDI_CONTAINER_SCHEMA',
|
|
24
|
-
hdi_user: 'HDI_DEPLOYER',
|
|
25
|
-
hdi_password: 'never-use-this'
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
test('resolves explicit read-only configuration and never disables TLS verification', () => {
|
|
31
|
-
const config = resolveHanaReadOnlyConfig(explicitEnv);
|
|
32
|
-
assert.equal(config.source, 'environment');
|
|
33
|
-
assert.equal(config.user, 'INSPECTOR');
|
|
34
|
-
assert.equal(config.password, 'do-not-print-this');
|
|
35
|
-
assert.equal(config.schema, 'HDI_CONTAINER_SCHEMA');
|
|
36
|
-
assert.equal(Object.isFrozen(config), true);
|
|
37
|
-
const options = hanaConnectionOptions(config);
|
|
38
|
-
assert.equal(options.encrypt, true);
|
|
39
|
-
assert.equal(options.sslValidateCertificate, true);
|
|
40
|
-
assert.equal(options.currentSchema, 'HDI_CONTAINER_SCHEMA');
|
|
41
|
-
assert.equal(options.pwd, 'do-not-print-this');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('rejects incomplete explicit credentials instead of falling back to VCAP', () => {
|
|
45
|
-
assert.throws(() => resolveHanaReadOnlyConfig({
|
|
46
|
-
...explicitEnv,
|
|
47
|
-
HANA_RO_PASSWORD: '',
|
|
48
|
-
VCAP_SERVICES: JSON.stringify({ hana: [vcapBinding('fallback')] })
|
|
49
|
-
}), /Explicit HANA read-only configuration is incomplete/);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('resolves one selected HANA VCAP binding without using HDI deployment credentials', () => {
|
|
53
|
-
const config = resolveHanaReadOnlyConfig({
|
|
54
|
-
VCAP_SERVICES: JSON.stringify({ hana: [vcapBinding('inspection-binding')] }),
|
|
55
|
-
HANA_RO_BINDING: 'inspection-binding'
|
|
56
|
-
});
|
|
57
|
-
assert.equal(config.source, 'vcap-services');
|
|
58
|
-
assert.equal(config.bindingName, 'inspection-binding');
|
|
59
|
-
assert.equal(config.serviceName, 'hana');
|
|
60
|
-
assert.equal(config.user, 'INSPECTOR');
|
|
61
|
-
assert.equal(config.schema, 'HDI_CONTAINER_SCHEMA');
|
|
62
|
-
assert.equal(config.password, 'vcap-secret-not-for-output');
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test('fails closed when VCAP contains multiple HANA bindings', () => {
|
|
66
|
-
const env = {
|
|
67
|
-
VCAP_SERVICES: JSON.stringify({ hana: [vcapBinding('first'), vcapBinding('second')] })
|
|
68
|
-
};
|
|
69
|
-
assert.throws(() => resolveHanaReadOnlyConfig(env), /Multiple HANA VCAP bindings/);
|
|
70
|
-
assert.equal(resolveHanaReadOnlyConfig({ ...env, HANA_RO_BINDING: 'second' }).bindingName, 'second');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test('rejects an HDI-deployer-only binding and does not disclose its credentials', () => {
|
|
74
|
-
const deployerOnly = {
|
|
75
|
-
name: 'deployer-only',
|
|
76
|
-
credentials: {
|
|
77
|
-
host: 'hana.example.test',
|
|
78
|
-
port: 443,
|
|
79
|
-
schema: 'HDI_CONTAINER_SCHEMA',
|
|
80
|
-
hdi_user: 'HDI_DEPLOYER',
|
|
81
|
-
hdi_password: 'never-use-this'
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
assert.throws(() => resolveHanaReadOnlyConfig({
|
|
85
|
-
VCAP_SERVICES: JSON.stringify({ hana: [deployerOnly] })
|
|
86
|
-
}), error => {
|
|
87
|
-
assert.match(error.message, /separate read-only binding/);
|
|
88
|
-
assert.doesNotMatch(error.message, /never-use-this/);
|
|
89
|
-
return true;
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('rejects a VCAP binding that aliases its read-only and deployment usernames', () => {
|
|
94
|
-
const binding = vcapBinding('aliased-binding', 'HDI_DEPLOYER');
|
|
95
|
-
assert.throws(() => resolveHanaReadOnlyConfig({
|
|
96
|
-
VCAP_SERVICES: JSON.stringify({ hana: [binding] })
|
|
97
|
-
}), /aliases its read-only and HDI deployment users/);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test('rejects bad host, port, missing schema, and missing VCAP configuration', () => {
|
|
101
|
-
assert.throws(() => resolveHanaReadOnlyConfig({ ...explicitEnv, HANA_RO_HOST: 'https://hana.example.test' }), /hostname/);
|
|
102
|
-
assert.throws(() => resolveHanaReadOnlyConfig({ ...explicitEnv, HANA_RO_PORT: '70000' }), /between 1 and 65535/);
|
|
103
|
-
assert.throws(() => resolveHanaReadOnlyConfig({ ...explicitEnv, HANA_RO_SCHEMA: '' }), /incomplete/);
|
|
104
|
-
assert.throws(() => resolveHanaReadOnlyConfig({}), /Set HANA_RO_\*/);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('quotes identifiers by doubling embedded quotes and rejects control characters', () => {
|
|
108
|
-
assert.equal(quoteHanaIdentifier('A"B'), '"A""B"');
|
|
109
|
-
assert.throws(() => quoteHanaIdentifier('bad\nname'), /identifier is invalid/);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test('serializes database calls, passes bind values, and redacts native driver errors', async () => {
|
|
113
|
-
const calls = [];
|
|
114
|
-
let disconnects = 0;
|
|
115
|
-
const connection = {
|
|
116
|
-
connect(options, callback) {
|
|
117
|
-
calls.push({ type: 'connect', options });
|
|
118
|
-
callback(null);
|
|
119
|
-
},
|
|
120
|
-
exec(sql, params, options, callback) {
|
|
121
|
-
calls.push({ type: 'exec', sql, params, options });
|
|
122
|
-
if (sql === 'FAIL') callback(Object.assign(new Error('password=do-not-print-this'), { code: 'HY000' }));
|
|
123
|
-
else callback(null, [{ OK: true }]);
|
|
124
|
-
},
|
|
125
|
-
disconnect(callback) {
|
|
126
|
-
disconnects += 1;
|
|
127
|
-
callback(null);
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
const db = new HanaDatabase(connection, resolveHanaReadOnlyConfig(explicitEnv));
|
|
131
|
-
await db.connect();
|
|
132
|
-
assert.deepEqual(await db.query('SELECT ? FROM DUMMY', ['value']), [{ OK: true }]);
|
|
133
|
-
await assert.rejects(db.query('FAIL'), error => {
|
|
134
|
-
assert.equal(error.name, 'HanaDatabaseError');
|
|
135
|
-
assert.match(error.message, /HANA error HY000/);
|
|
136
|
-
assert.doesNotMatch(error.message, /do-not-print-this/);
|
|
137
|
-
return true;
|
|
138
|
-
});
|
|
139
|
-
assert.equal(calls[0].options.sslValidateCertificate, true);
|
|
140
|
-
assert.equal(calls[1].params[0], 'value');
|
|
141
|
-
assert.deepEqual(calls[1].options, {});
|
|
142
|
-
await db.close();
|
|
143
|
-
await db.close();
|
|
144
|
-
assert.equal(disconnects, 1);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test('times out a hung read, requests cancellation, and retires its connection', async () => {
|
|
148
|
-
let cancels = 0;
|
|
149
|
-
let disconnects = 0;
|
|
150
|
-
const connection = {
|
|
151
|
-
connect(_options, callback) { callback(null); },
|
|
152
|
-
exec() {},
|
|
153
|
-
cancel() { cancels += 1; },
|
|
154
|
-
disconnect(callback) { disconnects += 1; callback(null); }
|
|
155
|
-
};
|
|
156
|
-
const db = new HanaDatabase(connection, resolveHanaReadOnlyConfig(explicitEnv), { queryTimeoutMs: 5 });
|
|
157
|
-
await db.connect();
|
|
158
|
-
await assert.rejects(db.query('SELECT HANG FROM DUMMY'), error => {
|
|
159
|
-
assert.equal(error.name, 'HanaQueryTimeoutError');
|
|
160
|
-
assert.match(error.message, /time limit/);
|
|
161
|
-
assert.doesNotMatch(error.message, /do-not-print-this/);
|
|
162
|
-
return true;
|
|
163
|
-
});
|
|
164
|
-
assert.equal(cancels, 1);
|
|
165
|
-
assert.equal(disconnects, 1);
|
|
166
|
-
assert.equal(db.unusable, true);
|
|
167
|
-
await assert.rejects(db.query('SELECT 1 FROM DUMMY'), /not available/);
|
|
168
|
-
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
-
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
|
-
|
|
7
|
-
const serverScript = fileURLToPath(new URL('../src/hana-inspector.mjs', import.meta.url));
|
|
8
|
-
|
|
9
|
-
test('standalone HANA MCP process lists tools through stdio without emitting secrets or diagnostics on stdout', async t => {
|
|
10
|
-
const transport = new StdioClientTransport({
|
|
11
|
-
command: process.execPath,
|
|
12
|
-
args: [serverScript],
|
|
13
|
-
cwd: process.cwd(),
|
|
14
|
-
env: {
|
|
15
|
-
PATH: process.env.PATH || '',
|
|
16
|
-
HANA_RO_HOST: 'hana.example.test',
|
|
17
|
-
HANA_RO_PORT: '443',
|
|
18
|
-
HANA_RO_USER: 'INSPECTOR',
|
|
19
|
-
HANA_RO_PASSWORD: 'never-emit-this-secret',
|
|
20
|
-
HANA_RO_SCHEMA: 'HDI_SCHEMA'
|
|
21
|
-
},
|
|
22
|
-
stderr: 'pipe'
|
|
23
|
-
});
|
|
24
|
-
const client = new Client({ name: 'hana-stdio-smoke', version: '1.0.0' });
|
|
25
|
-
const stderr = transport.stderr;
|
|
26
|
-
let stderrText = '';
|
|
27
|
-
stderr?.setEncoding('utf8');
|
|
28
|
-
stderr?.on('data', chunk => { stderrText += chunk; });
|
|
29
|
-
t.after(async () => {
|
|
30
|
-
await client.close().catch(() => {});
|
|
31
|
-
await transport.close().catch(() => {});
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
await client.connect(transport);
|
|
35
|
-
const listed = await client.listTools();
|
|
36
|
-
assert.deepEqual(listed.tools.map(tool => tool.name).sort(), [
|
|
37
|
-
'hana_connection_info',
|
|
38
|
-
'hana_describe_object',
|
|
39
|
-
'hana_list_objects',
|
|
40
|
-
'hana_read_rows'
|
|
41
|
-
]);
|
|
42
|
-
assert.equal(stderrText.includes('never-emit-this-secret'), false);
|
|
43
|
-
assert.equal(stderrText.includes('HANA_RO_PASSWORD='), false);
|
|
44
|
-
});
|
package/test/hana-tools.test.mjs
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
4
|
-
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
5
|
-
import { createHanaInspector } from '../src/hana-inspector.mjs';
|
|
6
|
-
|
|
7
|
-
const env = {
|
|
8
|
-
HANA_RO_HOST: 'hana.example.test',
|
|
9
|
-
HANA_RO_PORT: '443',
|
|
10
|
-
HANA_RO_USER: 'INSPECTOR',
|
|
11
|
-
HANA_RO_PASSWORD: 'test-secret',
|
|
12
|
-
HANA_RO_SCHEMA: 'HDI_SCHEMA'
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
function fakeDatabase({ rowResult = [{ ID: 7, TITLE: 'Catalog item' }], columns: columnOverride } = {}) {
|
|
16
|
-
const calls = [];
|
|
17
|
-
let closed = false;
|
|
18
|
-
const columns = columnOverride || [
|
|
19
|
-
{ COLUMN_NAME: 'ID', POSITION: 1, DATA_TYPE_NAME: 'INTEGER', LENGTH: 10, SCALE: 0, IS_NULLABLE: 'FALSE' },
|
|
20
|
-
{ COLUMN_NAME: 'TITLE', POSITION: 2, DATA_TYPE_NAME: 'NVARCHAR', LENGTH: 80, SCALE: 0, IS_NULLABLE: 'TRUE' }
|
|
21
|
-
];
|
|
22
|
-
return {
|
|
23
|
-
calls,
|
|
24
|
-
get closed() { return closed; },
|
|
25
|
-
async query(sql, values = []) {
|
|
26
|
-
calls.push({ sql, values });
|
|
27
|
-
if (sql.includes('CURRENT_SCHEMA')) return [{ CURRENT_SCHEMA: 'HDI_SCHEMA', CURRENT_USER: 'INSPECTOR' }];
|
|
28
|
-
if (sql.includes('FROM SYS.TABLES') && sql.includes('TABLE_NAME = ?')) {
|
|
29
|
-
return values[1] === 'BOOKS' ? [{ OBJECT_NAME: 'BOOKS' }] : [];
|
|
30
|
-
}
|
|
31
|
-
if (sql.includes('FROM SYS.VIEWS') && sql.includes('VIEW_NAME = ?')) {
|
|
32
|
-
return values[1] === 'BOOKS_VIEW' ? [{ OBJECT_NAME: 'BOOKS_VIEW' }] : [];
|
|
33
|
-
}
|
|
34
|
-
if (sql.includes('FROM SYS.TABLES') && sql.includes('ORDER BY TABLE_NAME')) return [{ OBJECT_NAME: 'BOOKS' }];
|
|
35
|
-
if (sql.includes('FROM SYS.VIEWS') && sql.includes('ORDER BY VIEW_NAME')) return [{ OBJECT_NAME: 'BOOKS_VIEW' }];
|
|
36
|
-
if (sql.includes('FROM SYS.TABLE_COLUMNS')) return columns;
|
|
37
|
-
if (sql.includes('FROM SYS.VIEW_COLUMNS')) return columns;
|
|
38
|
-
if (sql.startsWith('SELECT "')) return rowResult;
|
|
39
|
-
throw new Error(`Unexpected test SQL: ${sql}`);
|
|
40
|
-
},
|
|
41
|
-
async close() { closed = true; }
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function connectTestClient(t, database) {
|
|
46
|
-
const runtime = createHanaInspector({ env, databaseFactory: async () => database });
|
|
47
|
-
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
48
|
-
const client = new Client({ name: 'hana-test-client', version: '1.0.0' });
|
|
49
|
-
await runtime.server.connect(serverTransport);
|
|
50
|
-
await client.connect(clientTransport);
|
|
51
|
-
t.after(async () => {
|
|
52
|
-
await client.close().catch(() => {});
|
|
53
|
-
await runtime.close();
|
|
54
|
-
});
|
|
55
|
-
return { client, runtime };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function parseResult(response) {
|
|
59
|
-
assert.equal(response.isError, undefined, response.content?.[0]?.text);
|
|
60
|
-
return JSON.parse(response.content[0].text);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
test('advertises only bounded, read-only HANA tools with no arbitrary SQL input', async t => {
|
|
64
|
-
const database = fakeDatabase();
|
|
65
|
-
const { client } = await connectTestClient(t, database);
|
|
66
|
-
const result = await client.listTools();
|
|
67
|
-
const tools = new Map(result.tools.map(tool => [tool.name, tool]));
|
|
68
|
-
assert.deepEqual([...tools.keys()].sort(), [
|
|
69
|
-
'hana_connection_info',
|
|
70
|
-
'hana_describe_object',
|
|
71
|
-
'hana_list_objects',
|
|
72
|
-
'hana_read_rows'
|
|
73
|
-
]);
|
|
74
|
-
for (const tool of tools.values()) {
|
|
75
|
-
assert.equal(tool.annotations?.readOnlyHint, true);
|
|
76
|
-
assert.equal(tool.annotations?.destructiveHint, false);
|
|
77
|
-
}
|
|
78
|
-
const readRowsSchema = tools.get('hana_read_rows').inputSchema;
|
|
79
|
-
assert.ok(readRowsSchema.properties.objectName);
|
|
80
|
-
assert.ok(readRowsSchema.properties.columns);
|
|
81
|
-
assert.equal(readRowsSchema.properties.sql, undefined);
|
|
82
|
-
assert.equal(readRowsSchema.properties.schema, undefined);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test('returns connection identity and schema-scoped object/column metadata', async t => {
|
|
86
|
-
const database = fakeDatabase();
|
|
87
|
-
const { client } = await connectTestClient(t, database);
|
|
88
|
-
|
|
89
|
-
const identity = parseResult(await client.callTool({ name: 'hana_connection_info', arguments: {} }));
|
|
90
|
-
assert.equal(identity.currentSchema, 'HDI_SCHEMA');
|
|
91
|
-
assert.equal(identity.databaseUser, 'INSPECTOR');
|
|
92
|
-
assert.equal(identity.tls, 'enabled; certificate verification required');
|
|
93
|
-
assert.equal(JSON.stringify(identity).includes('test-secret'), false);
|
|
94
|
-
|
|
95
|
-
const objects = parseResult(await client.callTool({ name: 'hana_list_objects', arguments: { objectType: 'ALL', maxResults: 10 } }));
|
|
96
|
-
assert.deepEqual(objects.objects, [
|
|
97
|
-
{ objectName: 'BOOKS', objectType: 'TABLE' },
|
|
98
|
-
{ objectName: 'BOOKS_VIEW', objectType: 'VIEW' }
|
|
99
|
-
]);
|
|
100
|
-
assert.ok(database.calls.every(call => call.values.every(value => value !== 'OTHER_SCHEMA')));
|
|
101
|
-
|
|
102
|
-
const description = parseResult(await client.callTool({ name: 'hana_describe_object', arguments: { objectName: 'BOOKS' } }));
|
|
103
|
-
assert.equal(description.schema, 'HDI_SCHEMA');
|
|
104
|
-
assert.deepEqual(description.columns.map(column => column.COLUMN_NAME), ['ID', 'TITLE']);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('reads only catalog-verified columns with bound filters and a fixed HDI schema', async t => {
|
|
108
|
-
const database = fakeDatabase();
|
|
109
|
-
const { client } = await connectTestClient(t, database);
|
|
110
|
-
const response = parseResult(await client.callTool({
|
|
111
|
-
name: 'hana_read_rows',
|
|
112
|
-
arguments: {
|
|
113
|
-
objectName: 'BOOKS',
|
|
114
|
-
schema: 'OTHER_SCHEMA',
|
|
115
|
-
columns: ['ID', 'TITLE'],
|
|
116
|
-
filters: [{ column: 'ID', operator: 'gt', value: 4 }],
|
|
117
|
-
orderBy: { column: 'ID', direction: 'ASC' },
|
|
118
|
-
limit: 10
|
|
119
|
-
}
|
|
120
|
-
}));
|
|
121
|
-
assert.deepEqual(response.rows, [{ ID: 7, TITLE: 'Catalog item' }]);
|
|
122
|
-
const dataQuery = database.calls.find(call => call.sql.startsWith('SELECT "ID"'));
|
|
123
|
-
assert.ok(dataQuery);
|
|
124
|
-
assert.match(dataQuery.sql, /FROM "HDI_SCHEMA"\."BOOKS"/);
|
|
125
|
-
assert.doesNotMatch(dataQuery.sql, /OTHER_SCHEMA/);
|
|
126
|
-
assert.deepEqual(dataQuery.values, [4]);
|
|
127
|
-
assert.match(dataQuery.sql, /LIMIT 10$/);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test('binds hostile object names and rejects unknown columns without executing a data query', async t => {
|
|
131
|
-
const database = fakeDatabase();
|
|
132
|
-
const { client } = await connectTestClient(t, database);
|
|
133
|
-
const maliciousName = 'BOOKS"; DROP TABLE X;--';
|
|
134
|
-
const response = await client.callTool({
|
|
135
|
-
name: 'hana_read_rows',
|
|
136
|
-
arguments: { objectName: maliciousName, columns: ['ID'], limit: 1 }
|
|
137
|
-
});
|
|
138
|
-
assert.equal(response.isError, true);
|
|
139
|
-
assert.equal(database.calls.some(call => call.sql.includes('DROP TABLE')), false);
|
|
140
|
-
const lookup = database.calls.find(call => call.sql.includes('TABLE_NAME = ?'));
|
|
141
|
-
assert.deepEqual(lookup.values, ['HDI_SCHEMA', maliciousName]);
|
|
142
|
-
|
|
143
|
-
const invalidColumn = await client.callTool({
|
|
144
|
-
name: 'hana_read_rows',
|
|
145
|
-
arguments: { objectName: 'BOOKS', columns: ['SECRET'], limit: 1 }
|
|
146
|
-
});
|
|
147
|
-
assert.equal(invalidColumn.isError, true);
|
|
148
|
-
assert.equal(database.calls.some(call => call.sql.startsWith('SELECT "SECRET"')), false);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test('bounds object and row limits and refuses oversized results', async t => {
|
|
152
|
-
const largeDatabase = fakeDatabase({ rowResult: [{ ID: 1, TITLE: 'x'.repeat(100_000) }] });
|
|
153
|
-
const { client } = await connectTestClient(t, largeDatabase);
|
|
154
|
-
const tooManyObjects = await client.callTool({
|
|
155
|
-
name: 'hana_list_objects',
|
|
156
|
-
arguments: { objectType: 'TABLE', maxResults: 201 }
|
|
157
|
-
});
|
|
158
|
-
assert.equal(tooManyObjects.isError, true);
|
|
159
|
-
|
|
160
|
-
const tooLarge = await client.callTool({
|
|
161
|
-
name: 'hana_read_rows',
|
|
162
|
-
arguments: { objectName: 'BOOKS', columns: ['ID', 'TITLE'], limit: 1 }
|
|
163
|
-
});
|
|
164
|
-
assert.equal(tooLarge.isError, true);
|
|
165
|
-
assert.match(tooLarge.content[0].text, /too large to return safely/);
|
|
166
|
-
assert.ok(Buffer.byteLength(tooLarge.content[0].text) < 1000);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('rejects unbounded or oversized column selections before fetching row data', async t => {
|
|
170
|
-
const database = fakeDatabase({ columns: [
|
|
171
|
-
{ COLUMN_NAME: 'ID', POSITION: 1, DATA_TYPE_NAME: 'INTEGER', LENGTH: 10, SCALE: 0, IS_NULLABLE: 'FALSE' },
|
|
172
|
-
{ COLUMN_NAME: 'BODY', POSITION: 2, DATA_TYPE_NAME: 'NCLOB', LENGTH: 0, SCALE: 0, IS_NULLABLE: 'TRUE' },
|
|
173
|
-
{ COLUMN_NAME: 'WIDE', POSITION: 3, DATA_TYPE_NAME: 'NVARCHAR', LENGTH: 9000, SCALE: 0, IS_NULLABLE: 'TRUE' },
|
|
174
|
-
{ COLUMN_NAME: 'API_KEY', POSITION: 4, DATA_TYPE_NAME: 'NVARCHAR', LENGTH: 128, SCALE: 0, IS_NULLABLE: 'TRUE' }
|
|
175
|
-
] });
|
|
176
|
-
const { client } = await connectTestClient(t, database);
|
|
177
|
-
|
|
178
|
-
const lob = await client.callTool({
|
|
179
|
-
name: 'hana_read_rows',
|
|
180
|
-
arguments: { objectName: 'BOOKS', columns: ['BODY'], limit: 1 }
|
|
181
|
-
});
|
|
182
|
-
assert.equal(lob.isError, true);
|
|
183
|
-
assert.match(lob.content[0].text, /LOB and binary columns are not supported/);
|
|
184
|
-
|
|
185
|
-
const oversized = await client.callTool({
|
|
186
|
-
name: 'hana_read_rows',
|
|
187
|
-
arguments: { objectName: 'BOOKS', columns: ['WIDE'], limit: 1 }
|
|
188
|
-
});
|
|
189
|
-
assert.equal(oversized.isError, true);
|
|
190
|
-
assert.match(oversized.content[0].text, /safe row-width limit/);
|
|
191
|
-
const credential = await client.callTool({
|
|
192
|
-
name: 'hana_read_rows',
|
|
193
|
-
arguments: { objectName: 'BOOKS', columns: ['API_KEY'], limit: 1 }
|
|
194
|
-
});
|
|
195
|
-
assert.equal(credential.isError, true);
|
|
196
|
-
assert.match(credential.content[0].text, /Credential-like columns/);
|
|
197
|
-
assert.equal(database.calls.some(call => ['BODY', 'WIDE', 'API_KEY'].some(name => call.sql.startsWith(`SELECT "${name}"`))), false);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test('closes the connected database when the server runtime closes', async t => {
|
|
201
|
-
const database = fakeDatabase();
|
|
202
|
-
const { client, runtime } = await connectTestClient(t, database);
|
|
203
|
-
await client.callTool({ name: 'hana_connection_info', arguments: {} });
|
|
204
|
-
await runtime.close();
|
|
205
|
-
assert.equal(database.closed, true);
|
|
206
|
-
});
|