This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/surfaces/icd-test-runner/js/crud/knowledge.js
2026-03-14 19:36:33 +00:00

239 lines
11 KiB
JavaScript

/**
* ICD Test Runner — CRUD: Knowledge Bases
* KB lifecycle — create, read, update, documents, search, discoverable,
* channel binding, scope auth, cross-user isolation, delete.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.knowledge = async function (testTag) {
// ── KB CRUD ──
var kbId = null;
await T.test('crud', 'knowledge', 'POST /knowledge-bases (create)', async function () {
var d = await T.apiPost('/knowledge-bases', {
name: testTag + '-kb',
description: 'ICD integration test KB',
scope: 'personal'
});
T.assertShape(d, T.S.kb, 'kb');
T.assert(d.scope === 'personal', 'scope should be personal');
T.assert(d.status === 'active', 'status should be active');
T.assert(d.document_count === 0, 'new KB should have 0 documents');
T.assert(d.chunk_count === 0, 'new KB should have 0 chunks');
kbId = d.id;
T.registerCleanup(function () { if (kbId) return T.safeDelete('/knowledge-bases/' + kbId); });
});
if (!kbId) return;
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id (read)', async function () {
var d = await T.apiGet('/knowledge-bases/' + kbId);
T.assertShape(d, T.S.kb, 'kb');
T.assert(d.id === kbId, 'id mismatch');
});
await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id (update)', async function () {
var newName = testTag + '-kb-updated';
var d = await T.apiPut('/knowledge-bases/' + kbId, { name: newName });
T.assertShape(d, T.S.kb, 'kb-update');
T.assert(d.name === newName, 'name should have updated, got: ' + d.name);
});
await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id (empty body → 400)', async function () {
var token = await T.getAuthToken();
var resp = await T.authFetch(token, 'PUT', '/knowledge-bases/' + kbId, {});
T.assert(resp._status === 400, 'expected 400 for empty update, got ' + resp._status);
});
// ── Documents ──
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents (empty)', async function () {
var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents');
T.assertHasKey(d, 'data', '/kb-documents');
T.assert(Array.isArray(d.data), 'data should be array');
T.assert(d.data.length === 0, 'new KB should have 0 documents');
});
// Document upload → status → delete (requires object store)
var docId = null;
var storageOk = false;
try {
var ss = await T.apiGet('/admin/storage/status');
storageOk = ss && ss.configured === true;
} catch (e) { /* non-admin or storage not configured */ }
if (storageOk) {
await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/documents (upload)', async function () {
var blob = new Blob(['# ICD Test Document\n\nThis is test content for KB ingestion.'], { type: 'text/markdown' });
try {
var d = await T.apiUpload('/knowledge-bases/' + kbId + '/documents', blob, 'icd-test.md');
T.assert(d.id, 'expected document id');
T.assert(d.filename === 'icd-test.md', 'filename mismatch: ' + d.filename);
T.assert(d.status === 'pending', 'initial status should be pending, got: ' + d.status);
T.assert(d.kb_id === kbId, 'kb_id mismatch');
docId = d.id;
} catch (e) {
// 412 = no embedding model role configured (infrastructure, not contract bug)
if (e.message && (e.message.indexOf('412') !== -1 || e.message.indexOf('embedding') !== -1)) return;
throw e;
}
});
if (docId) {
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents/:docId/status', async function () {
var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents/' + docId + '/status');
T.assert(d.id === docId, 'doc id mismatch');
T.assert(typeof d.status === 'string', 'status should be string');
T.assert(typeof d.filename === 'string', 'filename should be string');
});
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents (has doc)', async function () {
var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents');
T.assertHasKey(d, 'data', '/kb-documents');
T.assert(d.data.length >= 1, 'should have at least 1 document');
});
await T.test('crud', 'knowledge', 'DELETE /knowledge-bases/:id/documents/:docId', async function () {
var token = await T.getAuthToken();
var resp = await fetch(T.base + '/api/v1/knowledge-bases/' + kbId + '/documents/' + docId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
credentials: 'same-origin'
});
T.assert(resp.ok, 'delete doc should succeed, got ' + resp.status);
var body = await resp.json();
T.assert(body.deleted === true, 'deleted should be true');
docId = null;
});
await T.test('crud', 'knowledge', 'GET /documents/:docId/status (deleted → 404)', async function () {
var token = await T.getAuthToken();
var resp = await T.authFetch(token, 'GET',
'/knowledge-bases/' + kbId + '/documents/00000000-0000-0000-0000-000000000099/status');
T.assert(resp._status === 404, 'expected 404 for deleted doc, got ' + resp._status);
});
}
}
// ── Search ──
await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/search', async function () {
try {
var d = await T.apiPost('/knowledge-bases/' + kbId + '/search', {
query: 'test', limit: 5
});
// Response envelope uses "data" key (not "results")
T.assertHasKey(d, 'data', '/kb-search');
T.assertHasKey(d, 'query', '/kb-search');
T.assertHasKey(d, 'total', '/kb-search');
T.assert(Array.isArray(d.data), 'data should be array');
} catch (e) {
// "failed to embed query" = no embedding model configured (infrastructure, not contract bug)
if (e.message && e.message.indexOf('embed') !== -1) return;
throw e;
}
});
// ── Rebuild (no documents = 400, or no ingester = 503) ──
await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/rebuild (empty → 400|503)', async function () {
var token = await T.getAuthToken();
var resp = await T.authFetch(token, 'POST', '/knowledge-bases/' + kbId + '/rebuild', {});
T.assert(resp._status === 400 || resp._status === 503,
'expected 400 (no docs) or 503 (no ingester), got ' + resp._status);
});
// ── Channel KB Binding ──
var bindChannelId = null;
await T.test('crud', 'knowledge', 'PUT /channels/:id/knowledge-bases (bind)', async function () {
// Create a temp channel
var ch = await T.apiPost('/channels', { title: testTag + '-kb-bind' });
bindChannelId = ch.id;
T.registerCleanup(function () { if (bindChannelId) return T.safeDelete('/channels/' + bindChannelId); });
// Bind KB
var d = await T.apiPut('/channels/' + bindChannelId + '/knowledge-bases', {
kb_ids: [kbId]
});
T.assertHasKey(d, 'data', '/channel-kb-set');
});
if (bindChannelId) {
await T.test('crud', 'knowledge', 'GET /channels/:id/knowledge-bases (verify)', async function () {
var d = await T.apiGet('/channels/' + bindChannelId + '/knowledge-bases');
T.assertHasKey(d, 'data', '/channel-kbs');
T.assert(d.data.length === 1, 'should have 1 linked KB, got ' + d.data.length);
var linked = d.data[0];
T.assert(linked.kb_id === kbId, 'linked kb_id mismatch');
T.assert(typeof linked.kb_name === 'string', 'kb_name should be string');
T.assert(typeof linked.enabled === 'boolean', 'enabled should be boolean');
});
await T.test('crud', 'knowledge', 'PUT /channels/:id/knowledge-bases (unbind)', async function () {
var d = await T.apiPut('/channels/' + bindChannelId + '/knowledge-bases', { kb_ids: [] });
T.assertHasKey(d, 'data', '/channel-kb-unbind');
T.assert(d.data.length === 0, 'should have 0 linked KBs after unbind');
});
}
// ── Discoverable ──
await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id/discoverable (toggle)', async function () {
var d = await T.apiPut('/knowledge-bases/' + kbId + '/discoverable', { discoverable: false });
T.assert(d.status === 'ok', 'expected status ok');
// Toggle back
d = await T.apiPut('/knowledge-bases/' + kbId + '/discoverable', { discoverable: true });
T.assert(d.status === 'ok', 'expected status ok on re-enable');
});
// ── Cross-user isolation (requires fixtures) ──
var fixtureUser = T.getFixtureUser ? T.getFixtureUser('-user') : null;
if (fixtureUser && fixtureUser.token) {
await T.test('crud', 'knowledge', 'isolation: other user cannot access personal KB', async function () {
var resp = await T.authFetch(fixtureUser.token, 'GET', '/knowledge-bases/' + kbId);
// Personal KB of admin should be hidden (404) from other user
T.assert(resp._status === 404, 'expected 404 for other user, got ' + resp._status);
});
await T.test('crud', 'knowledge', 'isolation: other user cannot delete personal KB', async function () {
var resp = await T.authFetch(fixtureUser.token, 'DELETE', '/knowledge-bases/' + kbId);
T.assert(resp._status === 404, 'expected 404 for delete by other user, got ' + resp._status);
});
await T.test('crud', 'knowledge', 'scope: non-admin global create → 403', async function () {
var resp = await T.authFetch(fixtureUser.token, 'POST', '/knowledge-bases', {
name: testTag + '-sneaky-global', scope: 'global'
});
T.assert(resp._status === 403, 'expected 403 for non-admin global KB, got ' + resp._status);
});
await T.test('crud', 'knowledge', 'discoverable: other user cannot toggle', async function () {
var resp = await T.authFetch(fixtureUser.token, 'PUT',
'/knowledge-bases/' + kbId + '/discoverable', { discoverable: false });
T.assert(resp._status === 404 || resp._status === 403,
'expected 403/404 for non-owner toggle, got ' + resp._status);
});
}
// ── Delete ──
await T.test('crud', 'knowledge', 'DELETE /knowledge-bases/:id', async function () {
await T.safeDelete('/knowledge-bases/' + kbId);
kbId = null;
});
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id (deleted → 404)', async function () {
var token = await T.getAuthToken();
var resp = await T.authFetch(token, 'GET', '/knowledge-bases/00000000-0000-0000-0000-000000000099');
T.assert(resp._status === 404, 'expected 404 for nonexistent KB, got ' + resp._status);
});
// Cleanup temp channel
if (bindChannelId) {
try { await T.safeDelete('/channels/' + bindChannelId); } catch (e) { /* ok */ }
bindChannelId = null;
}
};
})();