Changeset 0.28.7 (#193)

This commit is contained in:
2026-03-15 15:45:00 +00:00
parent bffda043db
commit 3237d55e0c
82 changed files with 2481 additions and 2771 deletions

View File

@@ -0,0 +1,110 @@
/**
* ICD Test Runner — CRUD Helpers
* Shared utilities for CRUD tier tests (ZIP builder, etc.).
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
// ── Minimal ZIP builder (no dependencies) ──
// Builds a valid ZIP archive from { filename: content } pairs.
// Content is UTF-8 strings. Returns a Blob suitable for FormData upload.
var crc32Table = null;
function crc32(bytes) {
if (!crc32Table) {
crc32Table = new Uint32Array(256);
for (var i = 0; i < 256; i++) {
var c = i;
for (var j = 0; j < 8; j++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
crc32Table[i] = c;
}
}
var crc = 0xFFFFFFFF;
for (var k = 0; k < bytes.length; k++) {
crc = crc32Table[(crc ^ bytes[k]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
T.crud.buildTestZip = function (files) {
var encoder = new TextEncoder();
var entries = [];
var offset = 0;
// Build file entries
Object.keys(files).forEach(function (name) {
var nameBytes = encoder.encode(name);
var dataBytes = encoder.encode(files[name]);
var c = crc32(dataBytes);
entries.push({ name: nameBytes, data: dataBytes, crc: c, offset: offset });
// Local file header (30) + name + data
offset += 30 + nameBytes.length + dataBytes.length;
});
// Calculate central directory
var cdOffset = offset;
var cdSize = 0;
entries.forEach(function (e) { cdSize += 46 + e.name.length; });
var totalSize = cdOffset + cdSize + 22; // + EOCD
var buf = new ArrayBuffer(totalSize);
var view = new DataView(buf);
var pos = 0;
// Write local file headers + data
entries.forEach(function (e) {
view.setUint32(pos, 0x04034b50, true); pos += 4; // signature
view.setUint16(pos, 20, true); pos += 2; // version needed
view.setUint16(pos, 0, true); pos += 2; // flags
view.setUint16(pos, 0, true); pos += 2; // compression (store)
view.setUint16(pos, 0, true); pos += 2; // mod time
view.setUint16(pos, 0, true); pos += 2; // mod date
view.setUint32(pos, e.crc, true); pos += 4; // crc32
view.setUint32(pos, e.data.length, true); pos += 4; // compressed size
view.setUint32(pos, e.data.length, true); pos += 4; // uncompressed size
view.setUint16(pos, e.name.length, true); pos += 2; // name length
view.setUint16(pos, 0, true); pos += 2; // extra length
new Uint8Array(buf, pos, e.name.length).set(e.name); pos += e.name.length;
new Uint8Array(buf, pos, e.data.length).set(e.data); pos += e.data.length;
});
// Write central directory
entries.forEach(function (e) {
view.setUint32(pos, 0x02014b50, true); pos += 4; // signature
view.setUint16(pos, 20, true); pos += 2; // version made by
view.setUint16(pos, 20, true); pos += 2; // version needed
view.setUint16(pos, 0, true); pos += 2; // flags
view.setUint16(pos, 0, true); pos += 2; // compression
view.setUint16(pos, 0, true); pos += 2; // mod time
view.setUint16(pos, 0, true); pos += 2; // mod date
view.setUint32(pos, e.crc, true); pos += 4; // crc32
view.setUint32(pos, e.data.length, true); pos += 4; // compressed size
view.setUint32(pos, e.data.length, true); pos += 4; // uncompressed size
view.setUint16(pos, e.name.length, true); pos += 2; // name length
view.setUint16(pos, 0, true); pos += 2; // extra length
view.setUint16(pos, 0, true); pos += 2; // comment length
view.setUint16(pos, 0, true); pos += 2; // disk number
view.setUint16(pos, 0, true); pos += 2; // internal attrs
view.setUint32(pos, 0, true); pos += 4; // external attrs
view.setUint32(pos, e.offset, true); pos += 4; // local header offset
new Uint8Array(buf, pos, e.name.length).set(e.name); pos += e.name.length;
});
// Write EOCD
view.setUint32(pos, 0x06054b50, true); pos += 4; // signature
view.setUint16(pos, 0, true); pos += 2; // disk number
view.setUint16(pos, 0, true); pos += 2; // cd start disk
view.setUint16(pos, entries.length, true); pos += 2; // entries on disk
view.setUint16(pos, entries.length, true); pos += 2; // total entries
view.setUint32(pos, cdSize, true); pos += 4; // cd size
view.setUint32(pos, cdOffset, true); pos += 4; // cd offset
view.setUint16(pos, 0, true); // comment length
return new Blob([buf], { type: 'application/zip' });
};
})();

View File

@@ -0,0 +1,142 @@
/**
* ICD Test Runner — CRUD: Admin
* Admin user lifecycle (create → deactivate → login reject → activate →
* login success → role change → password reset → vault reset → delete),
* admin settings roundtrip, channel purge.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.admin = async function (testTag) {
// ── Admin User Lifecycle (create → login fails → activate → login succeeds → cleanup) ──
if (T.user.role === 'admin') {
var testUserId = null;
var testUsername = 'icdtest' + (Date.now() % 100000);
var testPassword = 'IcdT3st!Pw' + (Date.now() % 10000);
await T.test('crud', 'admin', 'POST /admin/users (create)', async function () {
var d = await T.apiPost('/admin/users', {
username: testUsername,
password: testPassword,
email: testUsername + '@example.com',
role: 'user'
});
T.assertShape(d, T.S.adminUser, 'admin-user');
testUserId = d.id;
T.registerCleanup(function () { if (testUserId) return T.safeDelete('/admin/users/' + testUserId); });
});
if (testUserId) {
// Users start active (CreateUser sets IsActive=true).
// Deactivate first, then test inactive login rejection.
await T.test('crud', 'admin', 'PUT /admin/users/:id/active (deactivate)', async function () {
var d = await T.apiPut('/admin/users/' + testUserId + '/active', { is_active: false });
T.assert(typeof d === 'object', 'expected object');
});
await T.test('crud', 'admin', 'POST /auth/login (inactive user → reject)', async function () {
var d = await T.publicPost('/auth/login', {
login: testUsername,
password: testPassword
});
T.assert(d._status === 401 || d._status === 403,
'expected 401/403 for inactive user, got ' + d._status);
});
await T.test('crud', 'admin', 'PUT /admin/users/:id/active (activate)', async function () {
var d = await T.apiPut('/admin/users/' + testUserId + '/active', { is_active: true });
T.assert(typeof d === 'object', 'expected object');
});
var testUserToken = null;
await T.test('crud', 'admin', 'POST /auth/login (active user → success)', async function () {
var d = await T.publicPost('/auth/login', {
login: testUsername,
password: testPassword
});
T.assert(d._status === 200, 'expected 200, got ' + d._status +
(d.error ? ' (' + d.error + ')' : ''));
T.assertHasKey(d, 'access_token', 'login');
T.assertHasKey(d, 'refresh_token', 'login');
T.assertHasKey(d, 'user', 'login');
T.assert(d.user.username === testUsername, 'username mismatch');
testUserToken = d.access_token;
});
if (testUserToken) {
await T.test('crud', 'admin', 'GET /profile (as test user)', async function () {
var d = await T.authFetch(testUserToken, 'GET', '/profile');
T.assertStatus(d, 200, 'profile');
T.assert(d.username === testUsername, 'username mismatch');
});
await T.test('crud', 'admin', 'POST /auth/logout (test user)', async function () {
var d = await T.publicPost('/auth/logout', { refresh_token: '' });
T.assert(d._status === 200 || d._status === 401 || d._status === 204,
'expected 200/204/401, got ' + d._status);
});
}
await T.test('crud', 'admin', 'PUT /admin/users/:id/role (set admin)', async function () {
var d = await T.apiPut('/admin/users/' + testUserId + '/role', { role: 'admin' });
T.assert(typeof d === 'object', 'expected object');
});
await T.test('crud', 'admin', 'POST /admin/users/:id/reset-password', async function () {
var d = await T.apiPost('/admin/users/' + testUserId + '/reset-password', {
password: 'ResetPw!' + Date.now()
});
T.assert(typeof d === 'object', 'expected object');
T.assert(!d.error, 'unexpected error: ' + (d.error || ''));
});
await T.test('crud', 'admin', 'POST /admin/users/:id/vault/reset', async function () {
var d = await T.apiPost('/admin/users/' + testUserId + '/vault/reset', {});
// May return 200 with message, or 400 if vault not configured
T.assert(d._status === undefined || !d.error || d.error === 'vault not configured',
'unexpected error: ' + (d.error || ''));
});
await T.test('crud', 'admin', 'DELETE /admin/users/:id', async function () {
await T.safeDelete('/admin/users/' + testUserId);
testUserId = null;
});
}
// ── Admin Settings Roundtrip ──
await T.test('crud', 'admin', 'PUT /admin/settings/:key (policy roundtrip)', async function () {
// Read current value
var before = await T.apiGet('/admin/settings/allow_registration');
var originalVal = before.value;
// Write a known value
await T.apiPut('/admin/settings/allow_registration', { value: 'false' });
var after = await T.apiGet('/admin/settings/allow_registration');
T.assert(after.value === 'false', 'expected policy value "false"');
// Restore
if (originalVal !== undefined) {
await T.apiPut('/admin/settings/allow_registration', { value: String(originalVal) });
} else {
await T.apiPut('/admin/settings/allow_registration', { value: 'true' });
}
});
// ── Channel Purge ──
await T.test('crud', 'admin', 'DELETE /admin/channels/:id/purge (lifecycle)', async function () {
// Create a throwaway channel
var ch = await T.apiPost('/channels', { title: 'icd-purge-test', type: 'direct' });
T.assert(ch.id, 'expected channel id');
// Archive it
await T.apiPut('/channels/' + ch.id, { is_archived: true });
// Purge rejects non-archived channel attempts — ours is archived, so purge should succeed
var del = await T.apiDelete('/admin/channels/' + ch.id + '/purge');
T.assert(del.ok === true, 'expected { ok: true }');
});
}
};
})();

View File

@@ -0,0 +1,457 @@
/**
* ICD Test Runner — CRUD: Channels
* Channel lifecycle, DMs, folders, participants, model roster,
* KB linking, files, messages, typing, mark-read, user search.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.channels = async function (testTag) {
// ── Channels CRUD ──
var channelId = null;
await T.test('crud', 'channels', 'POST /channels (create)', async function () {
var d = await T.apiPost('/channels', {
title: testTag + '-channel',
type: 'direct',
description: 'ICD integration test channel',
tags: ['icd-test']
});
T.assertShape(d, T.S.channelFull, 'created channel');
T.assert(d.type === 'direct', 'type should be direct');
T.assert(d.is_archived === false, 'should not be archived');
channelId = d.id;
T.registerCleanup(function () { if (channelId) return T.safeDelete('/channels/' + channelId); });
});
if (channelId) {
await T.test('crud', 'channels', 'GET /channels/:id (read)', async function () {
var d = await T.apiGet('/channels/' + channelId);
T.assertShape(d, T.S.channelFull, 'channel');
T.assert(d.id === channelId, 'id mismatch');
T.assert(d.title.indexOf(testTag) !== -1, 'title mismatch');
});
await T.test('crud', 'channels', 'PUT /channels/:id (update title+topic+ai_mode)', async function () {
var d = await T.apiPut('/channels/' + channelId, {
title: testTag + '-updated',
topic: 'ICD test topic',
ai_mode: 'mention_only'
});
T.assert(d.title === testTag + '-updated', 'title not updated');
T.assert(d.topic === 'ICD test topic', 'topic not set');
T.assert(d.ai_mode === 'mention_only', 'ai_mode not set');
});
await T.test('crud', 'channels', 'PUT /channels/:id (restore ai_mode)', async function () {
var d = await T.apiPut('/channels/' + channelId, { ai_mode: 'auto' });
T.assert(d.ai_mode === 'auto', 'ai_mode not restored');
});
// ── Channel List Query Filters ──
await T.test('crud', 'channels', 'GET /channels?type=direct', async function () {
var d = await T.apiGet('/channels?type=direct&per_page=5');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
arr.forEach(function (ch) { T.assert(ch.type === 'direct', 'type filter leaked: ' + ch.type); });
});
await T.test('crud', 'channels', 'GET /channels?search=...', async function () {
var d = await T.apiGet('/channels?search=' + encodeURIComponent(testTag) + '&per_page=5');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
T.assert(arr.length >= 1, 'search should find our channel');
});
await T.test('crud', 'channels', 'GET /channels?archived=true (empty)', async function () {
var d = await T.apiGet('/channels?archived=true&per_page=5');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
// Our channel is not archived, so it shouldn't appear here
});
// ── Message Tree ──
await T.test('crud', 'channels', 'GET /channels/:id/path (empty)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/path');
T.assertHasKey(d, 'messages', '/path');
T.assert(Array.isArray(d.messages), 'messages should be array');
});
// ── Participants ──
await T.test('crud', 'channels', 'GET /channels/:id/participants (auto owner)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/participants');
T.assertHasKey(d, 'participants', '/participants');
T.assert(Array.isArray(d.participants), 'participants should be array');
T.assert(d.participants.length >= 1, 'should have at least owner participant');
if (d.participants.length > 0) {
T.assertShape(d.participants[0], T.S.participant, 'participant[0]');
T.assert(d.participants[0].role === 'owner', 'first participant should be owner');
}
});
// ── Participant CRUD (add fixture user, update role, remove) ──
var addedParticipantId = null;
var fixtureUser = T.getFixtureUser ? T.getFixtureUser('-user') : null;
if (fixtureUser && fixtureUser.id) {
await T.test('crud', 'channels', 'POST /channels/:id/participants (add user)', async function () {
var d = await T.apiPost('/channels/' + channelId + '/participants', {
participant_type: 'user',
participant_id: fixtureUser.id,
role: 'member'
});
// Response varies — may be participant object or list
var p = d.participant || d;
T.assert(p.id || p.participants, 'expected participant id or list');
if (p.id) addedParticipantId = p.id;
// Verify by re-listing
var list = await T.apiGet('/channels/' + channelId + '/participants');
var found = list.participants.find(function (pp) { return pp.participant_id === fixtureUser.id; });
T.assert(found, 'added user not found in participant list');
if (!addedParticipantId && found) addedParticipantId = found.id;
});
if (addedParticipantId) {
await T.test('crud', 'channels', 'PATCH /channels/:id/participants/:id (update role)', async function () {
var d = await T.apiPatch('/channels/' + channelId + '/participants/' + addedParticipantId, {
role: 'observer'
});
T.assert(typeof d === 'object', 'expected response');
// Verify
var list = await T.apiGet('/channels/' + channelId + '/participants');
var found = list.participants.find(function (pp) { return pp.id === addedParticipantId; });
T.assert(found && found.role === 'observer', 'role not updated to observer');
});
// ── Multi-user access: participant can GET channel (CS1 fix) ──
await T.test('crud', 'channels', 'GET /channels/:id (as participant, CS1 fix)', async function () {
var d = await T.authFetch(fixtureUser.token, 'GET', '/channels/' + channelId);
T.assert(d._status === 200, 'participant should see channel, got ' + d._status);
T.assert(d.id === channelId, 'id mismatch');
});
await T.test('crud', 'channels', 'GET /channels/:id/path (as participant)', async function () {
var d = await T.authFetch(fixtureUser.token, 'GET', '/channels/' + channelId + '/path');
T.assert(d._status === 200, 'participant should see path, got ' + d._status);
T.assertHasKey(d, 'messages', 'participant path');
});
await T.test('crud', 'channels', 'DELETE /channels/:id/participants/:id (remove)', async function () {
await T.apiDelete('/channels/' + channelId + '/participants/' + addedParticipantId);
var list = await T.apiGet('/channels/' + channelId + '/participants');
var found = list.participants.find(function (pp) { return pp.id === addedParticipantId; });
T.assert(!found, 'participant should be removed');
addedParticipantId = null;
});
}
}
// ── Model Roster CRUD ──
await T.test('crud', 'channels', 'GET /channels/:id/models (roster)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/models');
T.assertHasKey(d, 'models', '/channel-models');
T.assert(Array.isArray(d.models), 'models should be array');
});
var rosterModelId = null;
await T.test('crud', 'channels', 'POST /channels/:id/models (add)', async function () {
var d = await T.apiPost('/channels/' + channelId + '/models', {
model_id: 'icd-test-model',
display_name: 'ICD Test Model',
provider_config_id: null
});
T.assertHasKey(d, 'models', '/models add');
T.assert(Array.isArray(d.models), 'models should be array');
var added = d.models.find(function (m) { return m.model_id === 'icd-test-model'; });
T.assert(added, 'added model not found in roster');
rosterModelId = added ? added.id : null;
});
if (rosterModelId) {
await T.test('crud', 'channels', 'PATCH /channels/:id/models/:id (update)', async function () {
var d = await T.apiPatch('/channels/' + channelId + '/models/' + rosterModelId, {
display_name: 'ICD Updated Model'
});
T.assertHasKey(d, 'models', '/models update');
var updated = d.models.find(function (m) { return m.id === rosterModelId; });
T.assert(updated && updated.display_name === 'ICD Updated Model', 'display_name not updated');
});
await T.test('crud', 'channels', 'DELETE /channels/:id/models/:id (remove)', async function () {
var d = await T.apiDelete('/channels/' + channelId + '/models/' + rosterModelId);
T.assertHasKey(d, 'models', '/models delete');
var models = d.models || [];
var found = models.find(function (m) { return m.id === rosterModelId; });
T.assert(!found, 'deleted model still in roster');
rosterModelId = null;
});
}
// ── KB linking ──
await T.test('crud', 'channels', 'GET /channels/:id/knowledge-bases', async function () {
var d = await T.apiGet('/channels/' + channelId + '/knowledge-bases');
T.assertHasKey(d, 'data', '/channel-kbs');
});
// ── Files ──
await T.test('crud', 'channels', 'GET /channels/:id/files', async function () {
var d = await T.apiGet('/channels/' + channelId + '/files');
T.assertHasKey(d, 'files', '/channel-files');
T.assert(Array.isArray(d.files), 'files should be array');
});
// ── File Upload/Download Lifecycle (conditional on storage) ──
var storageOk = false;
if (T.user.role === 'admin') {
try {
var ss = await T.apiGet('/admin/storage/status');
storageOk = ss && ss.configured === true;
} catch (e) { /* not admin or storage disabled */ }
}
var uploadedFileId = null;
if (storageOk) {
await T.test('crud', 'channels', 'POST /channels/:id/files (upload)', async function () {
var blob = new Blob(['ICD test file content'], { type: 'text/plain' });
var d = await T.apiUpload('/channels/' + channelId + '/files', blob, 'icd-test.txt');
T.assert(d.id, 'expected file id');
T.assert(d.filename === 'icd-test.txt', 'filename mismatch');
T.assert(d.origin === 'user_upload', 'origin should be user_upload');
T.assert(d.content_type === 'text/plain', 'content_type mismatch');
uploadedFileId = d.id;
T.registerCleanup(function () { if (uploadedFileId) return T.safeDelete('/files/' + uploadedFileId); });
});
if (uploadedFileId) {
await T.test('crud', 'channels', 'GET /files/:id (metadata)', async function () {
var d = await T.apiGet('/files/' + uploadedFileId);
T.assertShape(d, T.S.file, 'file metadata');
T.assert(d.id === uploadedFileId, 'id mismatch');
T.assert(d.channel_id === channelId, 'channel_id mismatch');
});
await T.test('crud', 'channels', 'GET /files/:id/download', async function () {
var token = await T.getAuthToken();
var resp = await fetch(T.base + '/api/v1/files/' + uploadedFileId + '/download', {
headers: { 'Authorization': 'Bearer ' + token },
credentials: 'same-origin'
});
T.assert(resp.ok, 'download should succeed, got ' + resp.status);
var text = await resp.text();
T.assert(text === 'ICD test file content', 'download content mismatch');
});
await T.test('crud', 'channels', 'DELETE /files/:id', async function () {
var delId = uploadedFileId;
await T.safeDelete('/files/' + delId);
uploadedFileId = null;
// Verify gone
try {
await T.apiGet('/files/' + delId);
T.assert(false, 'file should be deleted');
} catch (e) {
T.assert(e.message.indexOf('404') !== -1 || e.message.indexOf('not found') !== -1, 'expected 404');
}
});
}
}
// ── Message Create + Edit + Cursor + Siblings ──
var msgId = null;
await T.test('crud', 'channels', 'POST /channels/:id/messages (create)', async function () {
var d = await T.apiPost('/channels/' + channelId + '/messages', {
role: 'user',
content: 'ICD integration test message'
});
T.assertShape(d, T.S.message, 'message');
msgId = d.id;
});
if (msgId) {
await T.test('crud', 'channels', 'GET /channels/:id/messages (all)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/messages');
var arr = d.messages || d.data;
T.assert(Array.isArray(arr), 'expected array');
T.assert(arr.length >= 1, 'expected at least 1 message');
});
await T.test('crud', 'channels', 'GET /channels/:id/messages/:id/siblings', async function () {
var d = await T.apiGet('/channels/' + channelId + '/messages/' + msgId + '/siblings');
T.assertHasKey(d, 'siblings', '/siblings');
T.assert(Array.isArray(d.siblings), 'siblings should be array');
});
// Edit creates sibling
var editedMsgId = null;
await T.test('crud', 'channels', 'POST /channels/:id/messages/:id/edit', async function () {
var d = await T.apiPost('/channels/' + channelId + '/messages/' + msgId + '/edit', {
content: 'ICD edited message content'
});
T.assertShape(d, T.S.message, 'edited message');
T.assert(d.id !== msgId, 'edit should create new message id');
T.assert(d.content === 'ICD edited message content', 'content mismatch');
editedMsgId = d.id;
});
if (editedMsgId) {
await T.test('crud', 'channels', 'GET siblings (after edit, expect 2)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/messages/' + msgId + '/siblings');
T.assert(d.siblings.length >= 2, 'edit should create sibling, got ' + d.siblings.length);
});
await T.test('crud', 'channels', 'PUT /channels/:id/cursor (switch branch)', async function () {
var d = await T.apiPut('/channels/' + channelId + '/cursor', {
active_leaf_id: msgId
});
T.assertHasKey(d, 'messages', '/cursor');
T.assert(Array.isArray(d.messages), 'cursor response should have messages array');
});
}
await T.test('crud', 'channels', 'GET /messages/:id/files', async function () {
var d = await T.apiGet('/messages/' + msgId + '/files');
T.assertHasKey(d, 'files', '/message-files');
T.assert(Array.isArray(d.files), 'files should be array');
});
await T.test('crud', 'channels', 'GET /channels/:id/path (after messages)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/path');
T.assertHasKey(d, 'messages', '/path');
T.assert(d.messages.length >= 1, 'path should have messages');
});
await T.test('crud', 'channels', 'POST /channels/:id/generate-title', async function () {
try {
var d = await T.apiPost('/channels/' + channelId + '/generate-title', {});
T.assert(typeof d === 'object', 'expected object');
} catch (e) {
if (e.message && (e.message.indexOf('400') !== -1 || e.message.indexOf('502') !== -1 || e.message.indexOf('model') !== -1))
return;
throw e;
}
});
}
// ── Typing indicator ──
await T.test('crud', 'channels', 'POST /channels/:id/typing', async function () {
var d = await T.apiPost('/channels/' + channelId + '/typing', {});
T.assert(d.ok === true, 'expected { ok: true }');
});
// ── Mark Read ──
await T.test('crud', 'channels', 'POST /channels/:id/mark-read', async function () {
var d = await T.apiPost('/channels/' + channelId + '/mark-read', {});
T.assert(typeof d === 'object', 'expected object');
});
// ── Delete ──
await T.test('crud', 'channels', 'DELETE /channels/:id', async function () {
await T.safeDelete('/channels/' + channelId);
channelId = null;
});
}
// ── DM Creation + Dedup ──
var dmChannelId = null;
var dmUser = T.getFixtureUser ? T.getFixtureUser('-user') : null;
if (dmUser && dmUser.id) {
await T.test('crud', 'channels', 'POST /channels (type=dm, create)', async function () {
var d = await T.apiPost('/channels', {
title: testTag + '-dm',
type: 'dm',
participants: [dmUser.id]
});
T.assert(d.id, 'DM channel should have id');
T.assert(d.type === 'dm', 'type should be dm');
dmChannelId = d.id;
T.registerCleanup(function () { if (dmChannelId) return T.safeDelete('/channels/' + dmChannelId); });
});
if (dmChannelId) {
await T.test('crud', 'channels', 'POST /channels (dm dedup → 200)', async function () {
var d = await T.apiPost('/channels', {
title: testTag + '-dm-dup',
type: 'dm',
participants: [dmUser.id]
});
T.assert(d.id === dmChannelId, 'dedup should return same channel id');
});
await T.test('crud', 'channels', 'GET /channels?types=dm (type filter)', async function () {
var d = await T.apiGet('/channels?types=dm&per_page=10');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
var found = arr.find(function (ch) { return ch.id === dmChannelId; });
T.assert(found, 'DM channel should appear in dm type filter');
});
await T.test('crud', 'channels', 'DELETE /channels/:id (dm cleanup)', async function () {
await T.safeDelete('/channels/' + dmChannelId);
dmChannelId = null;
});
}
}
// ── Folders CRUD + Channel Assignment ──
var folderId = null;
await T.test('crud', 'channels', 'POST /folders (create)', async function () {
var d = await T.apiPost('/folders', { name: testTag + '-folder', sort_order: 0 });
var folder = d.folder || d.data || d;
T.assert(folder.id || folder.ID, 'folder response missing id, got keys: ' + Object.keys(folder).join(', '));
folderId = folder.id || folder.ID;
T.registerCleanup(function () { if (folderId) return T.safeDelete('/folders/' + folderId); });
});
if (folderId) {
await T.test('crud', 'channels', 'PUT /folders/:id (update)', async function () {
var d = await T.apiPut('/folders/' + folderId, { name: testTag + '-folder-updated', sort_order: 1 });
T.assert(typeof d === 'object', 'expected object');
});
// Create a channel, assign to folder, verify filter works
var folderCh = null;
await T.test('crud', 'channels', 'PUT /channels/:id { folder_id } (assign)', async function () {
var ch = await T.apiPost('/channels', { title: testTag + '-folder-test', type: 'direct' });
folderCh = ch.id;
T.registerCleanup(function () { if (folderCh) return T.safeDelete('/channels/' + folderCh); });
var d = await T.apiPut('/channels/' + folderCh, { folder_id: folderId });
T.assert(d.folder_id === folderId, 'folder_id should be set, got: ' + d.folder_id);
});
if (folderCh) {
await T.test('crud', 'channels', 'GET /channels?folder_id=... (filter)', async function () {
var d = await T.apiGet('/channels?folder_id=' + folderId + '&per_page=10');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
var found = arr.find(function (ch) { return ch.id === folderCh; });
T.assert(found, 'channel should appear in folder_id filter');
});
await T.test('crud', 'channels', 'PUT /channels/:id { folder_id: "" } (unbind)', async function () {
var d = await T.apiPut('/channels/' + folderCh, { folder_id: '' });
T.assert(!d.folder_id, 'folder_id should be cleared');
});
await T.test('crud', 'channels', 'DELETE /channels/:id (folder test cleanup)', async function () {
await T.safeDelete('/channels/' + folderCh);
folderCh = null;
});
}
await T.test('crud', 'channels', 'DELETE /folders/:id', async function () {
await T.safeDelete('/folders/' + folderId);
folderId = null;
});
}
// ── User Search ──
await T.test('crud', 'channels', 'GET /users/search', async function () {
var d = await T.apiGet('/users/search?q=' + encodeURIComponent(T.user.username.substring(0, 3)));
T.assertHasKey(d, 'users', '/users/search');
T.assert(Array.isArray(d.users), 'users should be array');
});
};
})();

View File

@@ -0,0 +1,192 @@
/**
* ICD Test Runner — CRUD: Extensions
* Extension install lifecycle (install → list → update → user list →
* tier filter → manifest → tools → assets → settings → disable →
* duplicate 409 → invalid tier 400 → not found 404 → disabled asset 404 →
* delete), system extension disable rejection.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.extensions = async function (testTag) {
// ── Extensions CRUD ──
var extId = null; // UUID (DB id)
var extExtId = null; // ext_id (manifest id)
await T.test('crud', 'extensions', 'POST /admin/extensions (install)', async function () {
extExtId = testTag + '-ext';
var d = await T.apiPost('/admin/extensions', {
ext_id: extExtId,
name: testTag + ' Test Extension',
version: '1.0.0',
tier: 'browser',
description: 'ICD CRUD test extension',
author: 'icd-runner',
manifest: { _script: 'console.log("icd-test");', tools: [{ name: 'icd_tool', description: 'Test tool' }] },
is_enabled: true,
is_system: false
});
T.assertHasKey(d, 'data', 'install response');
T.assertShape(d.data, T.S.extension, 'installed extension');
T.assert(d.data.tier === 'browser', 'tier should be browser');
T.assert(d.data.enabled === true, 'should be enabled');
T.assert(d.data.is_system === false, 'should not be system');
extId = d.data.id;
T.registerCleanup(function () { if (extId) return T.safeDelete('/admin/extensions/' + extId); });
});
if (extId) {
await T.test('crud', 'extensions', 'GET /admin/extensions (list)', async function () {
var d = await T.apiGet('/admin/extensions');
T.assertHasKey(d, 'data', '/admin/extensions');
T.assert(Array.isArray(d.data), 'admin list should be array');
var found = d.data.some(function (e) { return e.id === extId; });
T.assert(found, 'installed extension should be in admin list');
});
await T.test('crud', 'extensions', 'PUT /admin/extensions/:id (update)', async function () {
var d = await T.apiPut('/admin/extensions/' + extId, {
name: testTag + ' Updated Extension',
version: '2.0.0',
description: 'Updated description'
});
T.assertHasKey(d, 'data', 'update response');
T.assert(d.data.title === testTag + ' Updated Extension', 'name not updated');
T.assert(d.data.version === '2.0.0', 'version not updated');
});
await T.test('crud', 'extensions', 'GET /extensions (user list)', async function () {
var d = await T.apiGet('/extensions');
T.assertHasKey(d, 'data', '/extensions');
T.assert(Array.isArray(d.data), 'user list should be array');
var found = d.data.some(function (e) { return e.id === extId; });
T.assert(found, 'enabled extension should appear in user list');
});
await T.test('crud', 'extensions', 'GET /extensions?tier=browser (filter)', async function () {
var d = await T.apiGet('/extensions?tier=browser');
T.assertHasKey(d, 'data', '/extensions?tier=browser');
var found = d.data.some(function (e) { return e.id === extId; });
T.assert(found, 'browser extension should appear with tier filter');
});
await T.test('crud', 'extensions', 'GET /extensions?tier=starlark (filter excludes)', async function () {
var d = await T.apiGet('/extensions?tier=starlark');
T.assertHasKey(d, 'data', '/extensions?tier=starlark');
var found = d.data.some(function (e) { return e.id === extId; });
T.assert(!found, 'browser extension should NOT appear with starlark filter');
});
await T.test('crud', 'extensions', 'GET /extensions/:id/manifest (by ext_id)', async function () {
var d = await T.apiGet('/extensions/' + extExtId + '/manifest');
T.assertHasKey(d, 'data', 'manifest response');
T.assert(typeof d.data === 'object', 'manifest should be object');
});
await T.test('crud', 'extensions', 'GET /extensions/tools (tool schemas)', async function () {
var d = await T.apiGet('/extensions/tools');
T.assertHasKey(d, 'data', '/extensions/tools');
T.assert(Array.isArray(d.data), 'tools should be array');
var found = d.data.some(function (t) { return t.name === 'icd_tool'; });
T.assert(found, 'icd_tool should appear in tool schemas');
});
await T.test('crud', 'extensions', 'GET /extensions/:id/assets (public, no auth)', async function () {
var resp = await fetch(T.base + '/api/v1/extensions/' + extExtId + '/assets/main.js');
T.assert(resp.ok, 'asset fetch should succeed: ' + resp.status);
var text = await resp.text();
T.assert(text.indexOf('icd-test') !== -1, 'script body should contain icd-test');
var ct = resp.headers.get('Content-Type') || '';
T.assert(ct.indexOf('javascript') !== -1, 'content-type should be javascript, got ' + ct);
});
await T.test('crud', 'extensions', 'POST /extensions/:id/settings (save)', async function () {
var d = await T.apiPost('/extensions/' + extId + '/settings', {
settings: { theme: 'dark' },
is_enabled: true
});
T.assertHasKey(d, 'ok', 'settings response');
T.assert(d.ok === true, 'ok should be true');
});
await T.test('crud', 'extensions', 'POST /extensions/:id/settings (disable non-system)', async function () {
var d = await T.apiPost('/extensions/' + extId + '/settings', { is_enabled: false });
T.assert(d.ok === true, 'should succeed for non-system extension');
// Re-enable for subsequent tests
await T.apiPost('/extensions/' + extId + '/settings', { is_enabled: true });
});
await T.test('crud', 'extensions', 'POST /admin/extensions (duplicate → 409)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'POST', '/admin/extensions', {
ext_id: extExtId, name: 'Duplicate'
});
T.assert(d._status === 409, 'duplicate ext_id should return 409, got ' + d._status);
});
await T.test('crud', 'extensions', 'POST /admin/extensions (invalid tier → 400)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'POST', '/admin/extensions', {
ext_id: 'bad-tier-ext', name: 'Bad Tier', tier: 'invalid'
});
T.assert(d._status === 400, 'invalid tier should return 400, got ' + d._status);
});
await T.test('crud', 'extensions', 'PUT /admin/extensions/:id (not found → 404)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/extensions/00000000-0000-0000-0000-000000000000', { name: 'ghost' });
T.assert(d._status === 404, 'nonexistent should return 404, got ' + d._status);
});
await T.test('crud', 'extensions', 'GET /extensions/:id/assets (disabled → 404)', async function () {
// Disable via admin
await T.apiPut('/admin/extensions/' + extId, { is_enabled: false });
// Cache-bust: the earlier asset fetch is cached (max-age=3600)
var resp = await fetch(T.base + '/api/v1/extensions/' + extExtId + '/assets/main.js?_cb=' + Date.now());
T.assert(resp.status === 404, 'disabled asset should 404, got ' + resp.status);
// Re-enable
await T.apiPut('/admin/extensions/' + extId, { is_enabled: true });
});
await T.test('crud', 'extensions', 'DELETE /admin/extensions/:id', async function () {
await T.safeDelete('/admin/extensions/' + extId);
// Verify gone from admin list
var d = await T.apiGet('/admin/extensions');
var found = d.data.some(function (e) { return e.id === extId; });
T.assert(!found, 'deleted extension should not appear in list');
extId = null;
});
}
// ── System extension disable rejection ──
var sysExtId = null;
await T.test('crud', 'extensions', 'POST /admin/extensions (system ext)', async function () {
var d = await T.apiPost('/admin/extensions', {
ext_id: testTag + '-sys-ext', name: 'System Test', is_system: true, is_enabled: true
});
T.assertHasKey(d, 'data', 'system install');
T.assert(d.data.is_system === true, 'should be system');
sysExtId = d.data.id;
T.registerCleanup(function () { if (sysExtId) return T.safeDelete('/admin/extensions/' + sysExtId); });
});
if (sysExtId) {
await T.test('crud', 'extensions', 'POST /extensions/:id/settings (system disable → 403)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'POST',
'/extensions/' + sysExtId + '/settings', { is_enabled: false });
T.assert(d._status === 403, 'disabling system ext should return 403, got ' + d._status);
});
await T.test('crud', 'extensions', 'DELETE /admin/extensions/:id (system cleanup)', async function () {
await T.safeDelete('/admin/extensions/' + sysExtId);
sysExtId = null;
});
}
};
})();

View File

@@ -0,0 +1,238 @@
/**
* 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;
}
};
})();

View File

@@ -0,0 +1,92 @@
/**
* ICD Test Runner — CRUD: Memory
* Memory listing, count shape, error paths, admin endpoints.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.memory = async function (testTag) {
// ── Memory CRUD ──
// No REST POST for memories (created by AI tools / background extraction).
// Test error paths, count shape, and admin endpoints.
var fakeMemId = '00000000-0000-0000-0000-000000000000';
await T.test('crud', 'memory', 'GET /memories (list, envelope shape)', async function () {
var d = await T.apiGet('/memories');
T.assertHasKey(d, 'data', '/memories');
T.assert(Array.isArray(d.data), 'data should be array');
// If memories exist, validate shape
if (d.data.length > 0) {
T.assertShape(d.data[0], T.S.memory, 'memory[0]');
}
});
await T.test('crud', 'memory', 'GET /memories?status=pending_review', async function () {
var d = await T.apiGet('/memories?status=pending_review');
T.assertHasKey(d, 'data', '/memories?status=pending_review');
T.assert(Array.isArray(d.data), 'data should be array');
});
await T.test('crud', 'memory', 'GET /memories/count (shape)', async function () {
var d = await T.apiGet('/memories/count');
T.assertHasKey(d, 'active', '/memories/count');
T.assertHasKey(d, 'pending', '/memories/count');
T.assert(typeof d.active === 'number', 'active should be number');
T.assert(typeof d.pending === 'number', 'pending should be number');
T.assert(d.active >= 0, 'active should be >= 0');
T.assert(d.pending >= 0, 'pending should be >= 0');
});
await T.test('crud', 'memory', 'PUT /memories/:id (not found → 404)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'PUT', '/memories/' + fakeMemId, { value: 'x' });
T.assertStatus(d, 404, 'update non-existent memory');
});
await T.test('crud', 'memory', 'DELETE /memories/:id (not found → 404)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'DELETE', '/memories/' + fakeMemId);
T.assertStatus(d, 404, 'delete non-existent memory');
});
await T.test('crud', 'memory', 'POST /memories/:id/approve (not found → 404)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/memories/' + fakeMemId + '/approve');
T.assertStatus(d, 404, 'approve non-existent memory');
});
await T.test('crud', 'memory', 'POST /memories/:id/reject (not found → 404)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/memories/' + fakeMemId + '/reject');
T.assertStatus(d, 404, 'reject non-existent memory');
});
if (T.user.role === 'admin') {
await T.test('crud', 'memory', 'GET /admin/memories/pending (admin)', async function () {
var d = await T.apiGet('/admin/memories/pending');
T.assertHasKey(d, 'data', '/admin/memories/pending');
T.assert(Array.isArray(d.data), 'data should be array');
if (d.data.length > 0) {
T.assertShape(d.data[0], T.S.memory, 'pending[0]');
}
});
await T.test('crud', 'memory', 'POST /admin/memories/bulk-approve (empty ids)', async function () {
var d = await T.apiPost('/admin/memories/bulk-approve', { ids: [] });
T.assertHasKey(d, 'approved', 'bulk-approve');
T.assert(d.approved === 0, 'empty ids should approve 0');
});
await T.test('crud', 'memory', 'POST /admin/memories/bulk-approve (bad body → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/admin/memories/bulk-approve', 'not-json');
T.assertStatus(d, 400, 'bad body');
});
}
};
})();

View File

@@ -0,0 +1,116 @@
/**
* ICD Test Runner — CRUD: Models
* Model preference lifecycle — hide/unhide, bulk, validation.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.models = async function (testTag) {
// ── Model Preferences CRUD ──
// Depends on provider setup from smoke tier — needs at least one model in /models/enabled
await T.test('crud', 'models', 'GET /models/preferences (initially empty for test user)', async function () {
var d = await T.apiGet('/models/preferences');
T.assertHasKey(d, 'data', '/models/preferences');
T.assert(Array.isArray(d.data), 'data should be array');
});
// Find a model to use for preference tests
var prefModelId = null;
var prefConfigId = null;
await T.test('crud', 'models', 'GET /models/enabled (pick model for pref test)', async function () {
var d = await T.apiGet('/models/enabled');
var models = d.data || [];
var catalog = models.filter(function (m) { return !m.is_persona && m.provider_config_id; });
if (catalog.length === 0) return; // no catalog models yet — skip pref write tests
prefModelId = catalog[0].model_id;
prefConfigId = catalog[0].provider_config_id;
});
if (prefModelId && prefConfigId) {
await T.test('crud', 'models', 'PUT /models/preferences (hide model)', async function () {
var d = await T.apiPut('/models/preferences', {
model_id: prefModelId,
provider_config_id: prefConfigId,
hidden: true
});
T.assert(d.message === 'preference updated', 'expected success message');
});
await T.test('crud', 'models', 'GET /models/preferences (verify hidden)', async function () {
var d = await T.apiGet('/models/preferences');
var prefs = d.data || [];
var found = prefs.find(function (p) {
return p.model_id === prefModelId && p.provider_config_id === prefConfigId;
});
T.assert(found, 'preference entry should exist after PUT');
T.assert(found.hidden === true, 'hidden should be true');
T.assertShape(found, T.S.modelPreference, 'preference');
});
await T.test('crud', 'models', 'GET /models/enabled (hidden flag propagated)', async function () {
var d = await T.apiGet('/models/enabled');
var models = d.data || [];
var found = models.find(function (m) {
return m.model_id === prefModelId && m.provider_config_id === prefConfigId;
});
if (found) {
T.assert(found.hidden === true, 'model should have hidden=true in /models/enabled');
}
});
await T.test('crud', 'models', 'PUT /models/preferences (unhide — upsert)', async function () {
var d = await T.apiPut('/models/preferences', {
model_id: prefModelId,
provider_config_id: prefConfigId,
hidden: false
});
T.assert(d.message === 'preference updated', 'expected success message');
});
await T.test('crud', 'models', 'GET /models/preferences (verify unhidden, no dup)', async function () {
var d = await T.apiGet('/models/preferences');
var prefs = d.data || [];
var matches = prefs.filter(function (p) {
return p.model_id === prefModelId && p.provider_config_id === prefConfigId;
});
T.assert(matches.length === 1, 'upsert should not duplicate: got ' + matches.length);
T.assert(matches[0].hidden === false, 'hidden should be false after unhide');
});
await T.test('crud', 'models', 'PUT /models/preferences (missing provider_config_id → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'PUT', '/models/preferences', { model_id: prefModelId, hidden: true });
T.assertStatus(d, 400, 'missing provider_config_id');
});
await T.test('crud', 'models', 'PUT /models/preferences (missing model_id → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'PUT', '/models/preferences', { provider_config_id: prefConfigId, hidden: true });
T.assertStatus(d, 400, 'missing model_id');
});
await T.test('crud', 'models', 'POST /models/preferences/bulk (hide)', async function () {
var d = await T.apiPost('/models/preferences/bulk', {
entries: [{ model_id: prefModelId, provider_config_id: prefConfigId }],
hidden: true
});
T.assert(d.message === 'preferences updated', 'expected bulk success message');
T.assert(d.count === 1, 'count should be 1');
});
await T.test('crud', 'models', 'POST /models/preferences/bulk (unhide cleanup)', async function () {
var d = await T.apiPost('/models/preferences/bulk', {
entries: [{ model_id: prefModelId, provider_config_id: prefConfigId }],
hidden: false
});
T.assert(d.message === 'preferences updated', 'expected bulk success message');
});
}
};
})();

View File

@@ -0,0 +1,64 @@
/**
* ICD Test Runner — CRUD: Notes
* Note lifecycle — create, read, update, search, backlinks, delete.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.notes = async function (testTag) {
// ── Notes CRUD ──
var noteId = null;
await T.test('crud', 'notes', 'POST /notes (create)', async function () {
var d = await T.apiPost('/notes', {
title: testTag + '-note',
content: '# ICD Test Note\n\nTest content with [[wikilink]].',
folder: 'icd-test'
});
T.assertShape(d, T.S.note, 'note');
noteId = d.id;
T.registerCleanup(function () { if (noteId) return T.safeDelete('/notes/' + noteId); });
});
if (noteId) {
await T.test('crud', 'notes', 'GET /notes/:id (read)', async function () {
var d = await T.apiGet('/notes/' + noteId);
T.assertShape(d, T.S.note, 'note');
T.assert(d.id === noteId, 'id mismatch');
T.assert(d.title.indexOf(testTag) !== -1, 'title mismatch');
});
await T.test('crud', 'notes', 'PUT /notes/:id (update)', async function () {
var d = await T.apiPut('/notes/' + noteId, {
title: testTag + '-note-updated',
content: '# Updated\n\nNew content.'
});
T.assert(d.title === testTag + '-note-updated' || (d.id && d.id === noteId), 'update response');
});
await T.test('crud', 'notes', 'GET /notes/search', async function () {
var d = await T.apiGet('/notes/search?q=' + encodeURIComponent(testTag));
T.assertHasKey(d, 'data', '/notes/search');
});
await T.test('crud', 'notes', 'GET /notes/search-titles', async function () {
var d = await T.apiGet('/notes/search-titles?q=' + encodeURIComponent(testTag));
T.assertHasKey(d, 'data', '/notes/search-titles');
});
await T.test('crud', 'notes', 'GET /notes/:id/backlinks', async function () {
var d = await T.apiGet('/notes/' + noteId + '/backlinks');
T.assertHasKey(d, 'data', '/backlinks');
});
await T.test('crud', 'notes', 'DELETE /notes/:id', async function () {
await T.safeDelete('/notes/' + noteId);
noteId = null;
});
}
};
})();

View File

@@ -0,0 +1,153 @@
/**
* ICD Test Runner — CRUD: Notifications
* Full preference lifecycle + notification endpoint error paths.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.notifications = async function (testTag) {
// ── Preference: Set ──
await T.test('crud', 'notifications', 'PUT /notifications/preferences/:type (set)', async function () {
var d = await T.apiPut('/notifications/preferences/kb.ready', {
in_app: true, email: true
});
T.assert(typeof d === 'object', 'expected object');
T.assert(d.type === 'kb.ready', 'type should be kb.ready, got: ' + d.type);
T.assert(d.in_app === true, 'in_app should be true');
T.assert(d.email === true, 'email should be true');
});
// ── Preference: Read back via list ──
await T.test('crud', 'notifications', 'GET /notifications/preferences (round-trip)', async function () {
var d = await T.apiGet('/notifications/preferences');
T.assertHasKey(d, 'data', 'preferences envelope');
T.assert(Array.isArray(d.data), 'data should be array');
var found = d.data.find(function (p) { return p.type === 'kb.ready'; });
T.assert(found, 'kb.ready preference should be in list');
T.assert(found.in_app === true, 'in_app should be true');
T.assert(found.email === true, 'email should be true');
});
// ── Preference: Partial update ──
await T.test('crud', 'notifications', 'PUT /notifications/preferences/:type (partial update)', async function () {
var d = await T.apiPut('/notifications/preferences/kb.ready', {
email: false
});
T.assert(d.in_app === true, 'in_app should remain true after partial update');
T.assert(d.email === false, 'email should be false after partial update');
});
// ── Preference: Wildcard ──
await T.test('crud', 'notifications', 'PUT /notifications/preferences/* (wildcard)', async function () {
var d = await T.apiPut('/notifications/preferences/*', {
in_app: false, email: true
});
T.assert(d.type === '*', 'type should be *, got: ' + d.type);
T.assert(d.in_app === false, 'in_app should be false');
T.assert(d.email === true, 'email should be true');
});
// ── Preference: Delete + verify removed ──
await T.test('crud', 'notifications', 'DELETE /notifications/preferences/:type (reset)', async function () {
await T.safeDelete('/notifications/preferences/kb.ready');
var d = await T.apiGet('/notifications/preferences');
var found = (d.data || []).find(function (p) { return p.type === 'kb.ready'; });
T.assert(!found, 'kb.ready should be removed after delete');
});
// ── Preference: Idempotent delete ──
await T.test('crud', 'notifications', 'DELETE /notifications/preferences/:type (idempotent)', async function () {
await T.safeDelete('/notifications/preferences/nonexistent.type');
});
// ── Cleanup wildcard preference ──
await T.test('crud', 'notifications', 'DELETE /notifications/preferences/* (cleanup)', async function () {
await T.safeDelete('/notifications/preferences/*');
});
// ── Notification list: envelope shape ──
await T.test('crud', 'notifications', 'GET /notifications (envelope shape)', async function () {
var d = await T.apiGet('/notifications');
T.assertHasKey(d, 'data', 'notifications envelope');
T.assertHasKey(d, 'total', 'notifications total');
T.assertHasKey(d, 'limit', 'notifications limit');
T.assertHasKey(d, 'offset', 'notifications offset');
T.assert(Array.isArray(d.data), 'data should be array');
T.assert(typeof d.total === 'number', 'total should be number');
T.assert(typeof d.limit === 'number', 'limit should be number');
T.assert(typeof d.offset === 'number', 'offset should be number');
});
// ── Notification list: pagination params ──
await T.test('crud', 'notifications', 'GET /notifications?limit=2 (pagination)', async function () {
var d = await T.apiGet('/notifications?limit=2&offset=0');
T.assert(d.limit === 2, 'limit should be 2, got: ' + d.limit);
T.assert(d.offset === 0, 'offset should be 0, got: ' + d.offset);
});
// ── Notification list: unread_only filter ──
await T.test('crud', 'notifications', 'GET /notifications?unread_only=true', async function () {
var d = await T.apiGet('/notifications?unread_only=true');
T.assertHasKey(d, 'data', 'unread envelope');
T.assert(Array.isArray(d.data), 'unread data should be array');
});
// ── Unread count: shape ──
await T.test('crud', 'notifications', 'GET /notifications/unread-count (shape)', async function () {
var d = await T.apiGet('/notifications/unread-count');
T.assertHasKey(d, 'count', 'unread-count');
T.assert(typeof d.count === 'number', 'count should be number');
});
// ── Mark all read: no-op on empty ──
await T.test('crud', 'notifications', 'POST /notifications/mark-all-read (no-op)', async function () {
var d = await T.apiPost('/notifications/mark-all-read');
T.assertHasKey(d, 'ok', 'mark-all-read');
T.assert(d.ok === true, 'ok should be true');
});
// ── Mark read: non-existent → 404 ──
await T.test('crud', 'notifications', 'PATCH /notifications/:id/read (not found → 404)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'PATCH', '/notifications/00000000-0000-0000-0000-000000000000/read');
T.assertStatus(d, 404, 'mark read non-existent');
});
// ── Delete: non-existent → 404 ──
await T.test('crud', 'notifications', 'DELETE /notifications/:id (not found → 404)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'DELETE', '/notifications/00000000-0000-0000-0000-000000000000');
T.assertStatus(d, 404, 'delete non-existent');
});
// ── Admin broadcast (v0.28.6) ──
await T.test('crud', 'notifications', 'POST /admin/notifications/broadcast', async function () {
var d = await T.apiPost('/admin/notifications/broadcast', {
title: 'ICD Test Broadcast',
message: 'Automated test broadcast.',
level: 'info'
});
T.assertHasKey(d, 'message', 'broadcast response');
T.assertHasKey(d, 'count', 'broadcast response');
T.assert(d.count >= 1, 'count should be >= 1');
});
await T.test('crud', 'notifications', 'POST /admin/notifications/broadcast (missing title → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/admin/notifications/broadcast', { message: 'no title' });
T.assertStatus(d, 400, 'missing title');
});
await T.test('crud', 'notifications', 'POST /admin/notifications/broadcast (invalid level → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/admin/notifications/broadcast', { title: 'x', message: 'x', level: 'extreme' });
T.assertStatus(d, 400, 'invalid level');
});
};
})();

View File

@@ -0,0 +1,247 @@
/**
* ICD Test Runner — CRUD: Personas
* Admin persona lifecycle (create → list → update → tool grants →
* KB bindings → delete), personal persona (policy-gated), team persona
* (fixture team), persona groups CRUD.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.personas = async function (testTag) {
// ── Personas CRUD (admin + personal + team + tool grants + groups) ──
var personaId = '';
var personalPersonaId = '';
var teamPersonaId = '';
// Admin persona CRUD
await T.test('crud', 'personas', 'POST /admin/personas (create global)', async function () {
var d = await T.apiPost('/admin/personas', {
name: 'ICD Test Persona', base_model_id: 'test-model',
system_prompt: 'You are a test persona.', description: 'Created by ICD test runner'
});
T.assert(d.id, 'persona should have id');
T.assert(d.scope === 'global', 'admin persona scope should be global');
T.assert(d.handle, 'handle should be auto-generated');
personaId = d.id;
});
if (personaId) {
await T.test('crud', 'personas', 'GET /admin/personas (list contains new)', async function () {
var d = await T.apiGet('/admin/personas');
T.assertHasKey(d, 'data', '/admin/personas');
var found = d.data.some(function (p) { return p.id === personaId; });
T.assert(found, 'created persona should appear in admin list');
});
await T.test('crud', 'personas', 'PUT /admin/personas/:id (update)', async function () {
var d = await T.apiPut('/admin/personas/' + personaId, { name: 'ICD Updated Persona' });
T.assert(d.message === 'persona updated', 'expected update confirmation');
});
// Tool grants
await T.test('crud', 'personas', 'GET /admin/personas/:id/tool-grants (empty)', async function () {
var d = await T.apiGet('/admin/personas/' + personaId + '/tool-grants');
T.assertHasKey(d, 'data', 'tool-grants');
T.assert(Array.isArray(d.data), 'grants should be array');
T.assert(d.data.length === 0, 'initial grants should be empty');
});
await T.test('crud', 'personas', 'PUT /admin/personas/:id/tool-grants (set)', async function () {
var d = await T.apiPut('/admin/personas/' + personaId + '/tool-grants', {
tool_names: ['web_search', 'calculator']
});
T.assert(d.status === 'ok', 'expected ok');
});
await T.test('crud', 'personas', 'GET /admin/personas/:id/tool-grants (verify)', async function () {
var d = await T.apiGet('/admin/personas/' + personaId + '/tool-grants');
T.assert(d.data.length === 2, 'should have 2 grants, got ' + d.data.length);
});
await T.test('crud', 'personas', 'PUT /admin/personas/:id/tool-grants (clear)', async function () {
await T.apiPut('/admin/personas/' + personaId + '/tool-grants', { tool_names: [] });
var d = await T.apiGet('/admin/personas/' + personaId + '/tool-grants');
T.assert(d.data.length === 0, 'grants should be empty after clear');
});
// KB bindings
await T.test('crud', 'personas', 'GET /admin/personas/:id/knowledge-bases (empty)', async function () {
var d = await T.apiGet('/admin/personas/' + personaId + '/knowledge-bases');
T.assertHasKey(d, 'data', 'persona-kbs');
T.assert(Array.isArray(d.data), 'kbs should be array');
});
await T.test('crud', 'personas', 'DELETE /admin/personas/:id', async function () {
await T.apiDelete('/admin/personas/' + personaId);
});
await T.test('crud', 'personas', 'GET /admin/personas (deleted gone)', async function () {
var d = await T.apiGet('/admin/personas');
var found = d.data.some(function (p) { return p.id === personaId; });
T.assert(!found, 'deleted persona should not appear in list');
});
}
// Personal persona (policy-gated)
await T.test('crud', 'personas', 'POST /personas (create personal)', async function () {
// Ensure policy allows personal personas
try { await T.apiPut('/admin/settings/allow_user_personas', { value: 'true' }); } catch (e) { /* may already be set */ }
var d = await T.apiPost('/personas', {
name: 'ICD Personal Bot', base_model_id: 'test-model',
system_prompt: 'Personal test.'
});
T.assert(d.id, 'personal persona should have id');
T.assert(d.scope === 'personal', 'scope should be personal');
personalPersonaId = d.id;
});
if (personalPersonaId) {
await T.test('crud', 'personas', 'PUT /personas/:id (update personal)', async function () {
var d = await T.apiPut('/personas/' + personalPersonaId, { description: 'updated desc' });
T.assert(d.message === 'persona updated', 'expected update confirmation');
});
await T.test('crud', 'personas', 'GET /personas (list contains personal)', async function () {
var d = await T.apiGet('/personas');
T.assertHasKey(d, 'data', '/personas');
var found = d.data.some(function (p) { return p.id === personalPersonaId; });
T.assert(found, 'personal persona should appear in user list');
});
await T.test('crud', 'personas', 'DELETE /personas/:id (delete personal)', async function () {
await T.apiDelete('/personas/' + personalPersonaId);
});
}
// Team persona (uses fixture team)
if (T.fixtures.team) {
var tId = T.fixtures.team.id;
await T.test('crud', 'personas', 'POST /teams/:teamId/personas (create)', async function () {
var d = await T.apiPost('/teams/' + tId + '/personas', {
name: 'ICD Team Bot', base_model_id: 'test-model',
system_prompt: 'Team test.'
});
T.assert(d.id, 'team persona should have id');
T.assert(d.scope === 'team', 'scope should be team');
teamPersonaId = d.id;
});
if (teamPersonaId) {
await T.test('crud', 'personas', 'PUT /teams/:teamId/personas/:id (update)', async function () {
var d = await T.apiPut('/teams/' + tId + '/personas/' + teamPersonaId, { name: 'ICD Team Bot v2' });
T.assert(d.message === 'persona updated', 'expected update confirmation');
});
await T.test('crud', 'personas', 'GET /teams/:teamId/personas/:id/tool-grants', async function () {
var d = await T.apiGet('/teams/' + tId + '/personas/' + teamPersonaId + '/tool-grants');
T.assertHasKey(d, 'data', 'team-tool-grants');
T.assert(Array.isArray(d.data), 'grants should be array');
});
await T.test('crud', 'personas', 'PUT /teams/:teamId/personas/:id/tool-grants (set)', async function () {
await T.apiPut('/teams/' + tId + '/personas/' + teamPersonaId + '/tool-grants', {
tool_names: ['kb_search']
});
var d = await T.apiGet('/teams/' + tId + '/personas/' + teamPersonaId + '/tool-grants');
T.assert(d.data.length === 1, 'team persona should have 1 grant');
});
await T.test('crud', 'personas', 'GET /teams/:teamId/personas/:id/knowledge-bases', async function () {
var d = await T.apiGet('/teams/' + tId + '/personas/' + teamPersonaId + '/knowledge-bases');
T.assertHasKey(d, 'data', 'team-persona-kbs');
});
await T.test('crud', 'personas', 'DELETE /teams/:teamId/personas/:id', async function () {
await T.apiDelete('/teams/' + tId + '/personas/' + teamPersonaId);
});
await T.test('crud', 'personas', 'GET /teams/:teamId/personas (deleted gone)', async function () {
var d = await T.apiGet('/teams/' + tId + '/personas');
var found = d.data.some(function (p) { return p.id === teamPersonaId; });
T.assert(!found, 'deleted team persona should not appear in list');
});
}
}
// Persona Groups CRUD
var pgId = '';
var pgMemberId = '';
await T.test('crud', 'personas', 'POST /persona-groups (create)', async function () {
var d = await T.apiPost('/persona-groups', { name: 'ICD Test Group', description: 'Test' });
T.assert(d.id, 'group should have id');
T.assert(d.scope === 'personal', 'group scope should be personal');
pgId = d.id;
});
if (pgId) {
await T.test('crud', 'personas', 'GET /persona-groups/:id (read)', async function () {
var d = await T.apiGet('/persona-groups/' + pgId);
T.assert(d.id === pgId, 'group id should match');
T.assert(d.name === 'ICD Test Group', 'group name should match');
T.assert(Array.isArray(d.members), 'members should be array');
});
await T.test('crud', 'personas', 'PUT /persona-groups/:id (update)', async function () {
var d = await T.apiPut('/persona-groups/' + pgId, { name: 'ICD Updated Group' });
T.assert(d.ok === true, 'expected ok');
});
await T.test('crud', 'personas', 'GET /persona-groups (list)', async function () {
var d = await T.apiGet('/persona-groups');
T.assertHasKey(d, 'data', '/persona-groups');
var found = d.data.some(function (g) { return g.id === pgId; });
T.assert(found, 'created group should appear in list');
});
// Add a member (need a persona — create a temporary one)
var tmpPersonaId = '';
await T.test('crud', 'personas', 'POST /admin/personas (for group member)', async function () {
var d = await T.apiPost('/admin/personas', {
name: 'Group Member Bot', base_model_id: 'test-model'
});
tmpPersonaId = d.id;
});
if (tmpPersonaId) {
await T.test('crud', 'personas', 'POST /persona-groups/:id/members (add)', async function () {
var d = await T.apiPost('/persona-groups/' + pgId + '/members', {
persona_id: tmpPersonaId, is_leader: true
});
T.assert(d.ok === true, 'expected ok');
});
await T.test('crud', 'personas', 'GET /persona-groups/:id (with member)', async function () {
var d = await T.apiGet('/persona-groups/' + pgId);
T.assert(d.members.length === 1, 'should have 1 member');
T.assert(d.members[0].is_leader === true, 'member should be leader');
pgMemberId = d.members[0].id;
});
if (pgMemberId) {
await T.test('crud', 'personas', 'DELETE /persona-groups/:id/members/:mid', async function () {
await T.apiDelete('/persona-groups/' + pgId + '/members/' + pgMemberId);
});
await T.test('crud', 'personas', 'GET /persona-groups/:id (member removed)', async function () {
var d = await T.apiGet('/persona-groups/' + pgId);
T.assert(d.members.length === 0, 'should have 0 members after removal');
});
}
// Cleanup temp persona
try { await T.apiDelete('/admin/personas/' + tmpPersonaId); } catch (e) { /* ok */ }
}
await T.test('crud', 'personas', 'DELETE /persona-groups/:id', async function () {
await T.apiDelete('/persona-groups/' + pgId);
});
}
};
})();

View File

@@ -0,0 +1,99 @@
/**
* ICD Test Runner — CRUD: Profile & Settings
* Profile update, settings merge round-trip, avatar lifecycle.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.profile = async function (testTag) {
// ── GET /profile — full shape (augments smoke) ──
var originalProfile = null;
await T.test('crud', 'profile', 'GET /profile (full shape)', async function () {
var d = await T.apiGet('/profile');
T.assertShape(d, T.S.profile, 'profile');
T.assert(typeof d.email === 'string' && d.email.length > 0, 'email must be non-empty string');
T.assert(typeof d.settings === 'object' && d.settings !== null, 'settings must be object (not null)');
originalProfile = d;
});
// ── PUT /profile — update display_name ──
if (originalProfile) {
await T.test('crud', 'profile', 'PUT /profile (display_name)', async function () {
var newName = testTag + '-display';
var d = await T.apiPut('/profile', { display_name: newName });
T.assertShape(d, T.S.profile, 'profile-after-update');
T.assert(d.display_name === newName, 'display_name not updated: ' + d.display_name);
});
// Restore original display_name
await T.test('crud', 'profile', 'PUT /profile (restore display_name)', async function () {
var d = await T.apiPut('/profile', { display_name: originalProfile.display_name || '' });
T.assertShape(d, T.S.profile, 'profile-restored');
});
}
// ── GET /settings — envelope with empty object for clean user ──
await T.test('crud', 'profile', 'GET /settings (envelope)', async function () {
var d = await T.apiGet('/settings');
T.assertHasKey(d, 'settings', 'GET /settings');
T.assert(typeof d.settings === 'object' && d.settings !== null, 'settings must be object');
});
// ── PUT /settings — create, then merge, then verify ──
await T.test('crud', 'profile', 'PUT /settings (initial)', async function () {
var d = await T.apiPut('/settings', {
icd_test_key: 'alpha',
icd_test_other: 'keep-me'
});
T.assertHasKey(d, 'settings', 'PUT /settings response');
T.assert(d.settings.icd_test_key === 'alpha', 'icd_test_key should be alpha');
T.assert(d.settings.icd_test_other === 'keep-me', 'icd_test_other should be set');
});
await T.test('crud', 'profile', 'PUT /settings (merge overwrites + preserves)', async function () {
var d = await T.apiPut('/settings', { icd_test_key: 'beta' });
T.assertHasKey(d, 'settings', 'PUT /settings merge');
T.assert(d.settings.icd_test_key === 'beta', 'icd_test_key should be overwritten to beta');
T.assert(d.settings.icd_test_other === 'keep-me', 'icd_test_other should be preserved');
});
await T.test('crud', 'profile', 'GET /settings (round-trip verify)', async function () {
var d = await T.apiGet('/settings');
T.assert(d.settings.icd_test_key === 'beta', 'GET round-trip: icd_test_key should be beta');
T.assert(d.settings.icd_test_other === 'keep-me', 'GET round-trip: icd_test_other preserved');
});
// ── Avatar lifecycle: upload → verify in profile → delete → verify gone ──
// Build a minimal 1x1 red PNG as base64
var tinyPNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
await T.test('crud', 'profile', 'POST /profile/avatar (upload)', async function () {
var d = await T.apiPost('/profile/avatar', { image: tinyPNG });
T.assertHasKey(d, 'avatar', 'avatar-upload');
T.assert(typeof d.avatar === 'string' && d.avatar.indexOf('data:image/png') === 0, 'avatar should be data URI');
});
await T.test('crud', 'profile', 'GET /profile (avatar present)', async function () {
var d = await T.apiGet('/profile');
T.assert(d.avatar && typeof d.avatar === 'string', 'profile should have avatar after upload');
});
await T.test('crud', 'profile', 'DELETE /profile/avatar', async function () {
await T.safeDelete('/profile/avatar');
});
await T.test('crud', 'profile', 'GET /profile (avatar cleared)', async function () {
var d = await T.apiGet('/profile');
T.assert(!d.avatar, 'avatar should be null/absent after delete');
});
// ── Clean up test settings keys ──
// Overwrite with empty values — there's no DELETE for individual keys
// so just leave them; they're namespaced with icd_test_ prefix.
};
})();

View File

@@ -0,0 +1,194 @@
/**
* ICD Test Runner — CRUD: Projects
* Full project lifecycle — CRUD, channel/KB/note associations, files,
* isolation, and admin list.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.projects = async function (testTag) {
// ── Create ──
var projectId = null;
await T.test('crud', 'projects', 'POST /projects (create, 201 + shape)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/projects', {
name: testTag + '-project',
description: 'ICD integration test project',
color: '#3b82f6',
icon: 'folder'
});
T.assertStatus(d, 201, 'POST /projects');
T.assertShape(d, T.S.project, 'project');
T.assert(d.scope === 'personal', 'scope forced to personal, got ' + d.scope);
T.assert(typeof d.owner_id === 'string' && d.owner_id.length > 0, 'owner_id must be set');
projectId = d.id;
T.registerCleanup(function () { if (projectId) return T.safeDelete('/projects/' + projectId); });
});
if (!projectId) return;
// ── Read ──
await T.test('crud', 'projects', 'GET /projects/:id (read, shape)', async function () {
var d = await T.apiGet('/projects/' + projectId);
T.assertShape(d, T.S.project, 'project');
T.assert(d.id === projectId, 'id mismatch');
T.assert(d.name === testTag + '-project', 'name mismatch');
T.assert(d.description === 'ICD integration test project', 'description mismatch');
});
// ── Update ──
await T.test('crud', 'projects', 'PUT /projects/:id (update, returns refreshed)', async function () {
var d = await T.apiPut('/projects/' + projectId, { name: testTag + '-proj-updated', is_archived: false });
T.assertShape(d, T.S.project, 'project');
T.assert(d.name === testTag + '-proj-updated', 'name not updated');
T.assert(d.id === projectId, 'id changed unexpectedly');
});
// ── List envelope ──
await T.test('crud', 'projects', 'GET /projects (list envelope)', async function () {
var d = await T.apiGet('/projects');
T.assertHasKey(d, 'data', '/projects');
T.assert(Array.isArray(d.data), 'data must be array');
var found = d.data.some(function (p) { return p.id === projectId; });
T.assert(found, 'created project not in list');
});
// ── Channel association ──
var channelId = null;
await T.test('crud', 'projects', 'POST /projects/:id/channels (add channel)', async function () {
// Create a scratch channel for association testing
var ch = await T.apiPost('/channels', { title: testTag + '-proj-ch', type: 'direct' });
channelId = ch.id;
T.registerCleanup(function () { if (channelId) return T.safeDelete('/channels/' + channelId); });
var d = await T.apiPost('/projects/' + projectId + '/channels', { channel_id: channelId, position: 0 });
T.assert(typeof d === 'object', 'expected object response');
});
await T.test('crud', 'projects', 'GET /projects/:id/channels (list, has added)', async function () {
var d = await T.apiGet('/projects/' + projectId + '/channels');
T.assertHasKey(d, 'data', '/project-channels');
T.assert(Array.isArray(d.data), 'data must be array');
T.assert(d.data.length >= 1, 'expected at least 1 channel association');
var entry = d.data[0];
T.assert(typeof entry.channel_id === 'string', 'missing channel_id');
T.assert(typeof entry.project_id === 'string', 'missing project_id');
T.assert(typeof entry.added_at === 'string', 'missing added_at');
});
await T.test('crud', 'projects', 'DELETE /projects/:id/channels/:channelId (remove)', async function () {
await T.apiDelete('/projects/' + projectId + '/channels/' + channelId);
var d = await T.apiGet('/projects/' + projectId + '/channels');
T.assert(d.data.length === 0, 'channel should be removed');
});
// ── KB association ──
var kbId = null;
await T.test('crud', 'projects', 'POST /projects/:id/knowledge-bases (add KB)', async function () {
// Create a scratch KB
var kb = await T.apiPost('/knowledge-bases', { name: testTag + '-proj-kb', scope: 'personal' });
kbId = kb.id;
T.registerCleanup(function () { if (kbId) return T.safeDelete('/knowledge-bases/' + kbId); });
var d = await T.apiPost('/projects/' + projectId + '/knowledge-bases', { kb_id: kbId, auto_search: true });
T.assert(typeof d === 'object', 'expected object response');
});
await T.test('crud', 'projects', 'GET /projects/:id/knowledge-bases (list, has added)', async function () {
var d = await T.apiGet('/projects/' + projectId + '/knowledge-bases');
T.assertHasKey(d, 'data', '/project-kbs');
T.assert(d.data.length >= 1, 'expected at least 1 KB association');
var entry = d.data[0];
T.assert(typeof entry.kb_id === 'string', 'missing kb_id');
T.assert(typeof entry.project_id === 'string', 'missing project_id');
T.assert(typeof entry.added_at === 'string', 'missing added_at');
});
await T.test('crud', 'projects', 'DELETE /projects/:id/knowledge-bases/:kbId (remove)', async function () {
await T.apiDelete('/projects/' + projectId + '/knowledge-bases/' + kbId);
var d = await T.apiGet('/projects/' + projectId + '/knowledge-bases');
T.assert(d.data.length === 0, 'KB should be removed');
});
// ── Note association ──
var noteId = null;
await T.test('crud', 'projects', 'POST /projects/:id/notes (add note)', async function () {
// Create a scratch note
var n = await T.apiPost('/notes', { title: testTag + '-proj-note', content: 'test' });
noteId = n.id;
T.registerCleanup(function () { if (noteId) return T.safeDelete('/notes/' + noteId); });
var d = await T.apiPost('/projects/' + projectId + '/notes', { note_id: noteId });
T.assert(typeof d === 'object', 'expected object response');
});
await T.test('crud', 'projects', 'GET /projects/:id/notes (list, has added)', async function () {
var d = await T.apiGet('/projects/' + projectId + '/notes');
T.assertHasKey(d, 'data', '/project-notes');
T.assert(d.data.length >= 1, 'expected at least 1 note association');
var entry = d.data[0];
T.assert(typeof entry.note_id === 'string', 'missing note_id');
T.assert(typeof entry.project_id === 'string', 'missing project_id');
T.assert(typeof entry.added_at === 'string', 'missing added_at');
});
await T.test('crud', 'projects', 'DELETE /projects/:id/notes/:noteId (remove)', async function () {
await T.apiDelete('/projects/' + projectId + '/notes/' + noteId);
var d = await T.apiGet('/projects/' + projectId + '/notes');
T.assert(d.data.length === 0, 'note should be removed');
});
// ── Files (shape only — no upload in runner) ──
await T.test('crud', 'projects', 'GET /projects/:id/files (files key + count)', async function () {
var d = await T.apiGet('/projects/' + projectId + '/files');
T.assertHasKey(d, 'files', '/project-files');
T.assertHasKey(d, 'count', '/project-files');
T.assert(Array.isArray(d.files), 'files must be array');
T.assert(typeof d.count === 'number', 'count must be number');
});
// ── Isolation ──
await T.test('crud', 'projects', 'isolation: other user cannot GET personal project', async function () {
var other = T.getFixtureUser('-user');
if (!other || !other.token) { T.assert(true, 'skip — no fixture user'); return; }
var d = await T.authFetch(other.token, 'GET', '/projects/' + projectId);
T.assert(d._status === 404, 'expected 404 for other user, got ' + d._status);
});
await T.test('crud', 'projects', 'isolation: other user cannot DELETE personal project', async function () {
var other = T.getFixtureUser('-user');
if (!other || !other.token) { T.assert(true, 'skip — no fixture user'); return; }
var d = await T.authFetch(other.token, 'DELETE', '/projects/' + projectId);
T.assert(d._status === 404, 'expected 404 for other user DELETE, got ' + d._status);
});
// ── Admin list ──
await T.test('crud', 'projects', 'GET /admin/projects (envelope + owner_name)', async function () {
var d = await T.apiGet('/admin/projects');
T.assertHasKey(d, 'data', '/admin/projects');
T.assert(Array.isArray(d.data), 'data must be array');
var found = d.data.find(function (p) { return p.id === projectId; });
T.assert(found, 'created project not in admin list');
T.assert(typeof found.owner_name === 'string', 'admin list must include owner_name');
T.assert(typeof found.channel_count === 'number', 'admin list must include channel_count');
T.assert(typeof found.kb_count === 'number', 'admin list must include kb_count');
T.assert(typeof found.note_count === 'number', 'admin list must include note_count');
});
// ── Delete ──
await T.test('crud', 'projects', 'DELETE /projects/:id', async function () {
await T.safeDelete('/projects/' + projectId);
projectId = null;
});
// Cleanup scratch resources (cleanups run in reverse but let's be explicit)
if (channelId) { try { await T.safeDelete('/channels/' + channelId); channelId = null; } catch (e) { /* ok */ } }
if (kbId) { try { await T.safeDelete('/knowledge-bases/' + kbId); kbId = null; } catch (e) { /* ok */ } }
if (noteId) { try { await T.safeDelete('/notes/' + noteId); noteId = null; } catch (e) { /* ok */ } }
};
})();

View File

@@ -0,0 +1,204 @@
/**
* ICD Test Runner — CRUD: Surfaces
* Surface install lifecycle (install → read → admin list → user list →
* disable → hidden from user → still in admin → enable → visible again →
* core disable rejection → not found → disable not found → missing manifest →
* core conflict → reseed preserves enabled → delete → delete not found).
* Uses T.crud.buildTestZip() from _helpers.js.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
var buildTestZip = T.crud.buildTestZip;
T.crud.surfaces = async function (testTag) {
// ── Surfaces CRUD ──
var surfaceId = null;
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (happy path)', async function () {
surfaceId = 'icd-test-' + Date.now();
var manifest = JSON.stringify({
id: surfaceId,
title: 'ICD Test Surface',
route: '/s/' + surfaceId
});
var zipBlob = buildTestZip({ 'manifest.json': manifest });
await T.apiUpload('/admin/surfaces/install', zipBlob, surfaceId + '.surface');
// Verify it exists
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
T.assert(d.id === surfaceId, 'installed surface id mismatch');
T.assert(d.title === 'ICD Test Surface', 'title mismatch');
T.registerCleanup(async function () {
if (surfaceId) {
var token = await T.getAuthToken();
return T.authFetch(token, 'DELETE', '/admin/surfaces/' + surfaceId);
}
});
});
if (surfaceId) {
await T.test('crud', 'surfaces', 'GET /admin/surfaces/:id (read)', async function () {
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
T.assertShape(d, T.S.surfaceAdmin, 'surface detail');
T.assert(d.id === surfaceId, 'id mismatch');
T.assert(d.source === 'extension', 'source should be extension');
T.assert(d.enabled === true, 'newly installed surface should be enabled');
});
await T.test('crud', 'surfaces', 'GET /admin/surfaces (list includes new)', async function () {
var d = await T.apiGet('/admin/surfaces');
T.assertHasKey(d, 'data', '/admin/surfaces');
var found = d.data.some(function (s) { return s.id === surfaceId; });
T.assert(found, 'installed surface should appear in admin list');
});
await T.test('crud', 'surfaces', 'GET /surfaces (user list includes new)', async function () {
var d = await T.apiGet('/surfaces');
T.assertHasKey(d, 'data', '/surfaces');
var found = d.data.some(function (s) { return s.id === surfaceId; });
T.assert(found, 'enabled extension surface should appear in user nav list');
// Verify nav shape: id + title + route, NOT source/enabled
var entry = d.data.find(function (s) { return s.id === surfaceId; });
T.assert(entry.route !== undefined, 'nav entry should have route');
T.assert(entry.source === undefined, 'nav entry should NOT have source');
T.assert(entry.enabled === undefined, 'nav entry should NOT have enabled');
});
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/:id/disable', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/' + surfaceId + '/disable');
T.assertStatus(d, 200, 'disable');
T.assert(d.enabled === false, 'should report disabled');
});
await T.test('crud', 'surfaces', 'GET /surfaces (disabled hidden from user)', async function () {
var d = await T.apiGet('/surfaces');
var found = d.data.some(function (s) { return s.id === surfaceId; });
T.assert(!found, 'disabled surface should NOT appear in user nav list');
});
await T.test('crud', 'surfaces', 'GET /admin/surfaces (disabled still in admin)', async function () {
var d = await T.apiGet('/admin/surfaces');
var entry = d.data.find(function (s) { return s.id === surfaceId; });
T.assert(entry, 'disabled surface should still appear in admin list');
T.assert(entry.enabled === false, 'should show as disabled in admin list');
});
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/:id/enable', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/' + surfaceId + '/enable');
T.assertStatus(d, 200, 'enable');
T.assert(d.enabled === true, 'should report enabled');
});
await T.test('crud', 'surfaces', 'GET /surfaces (re-enabled visible)', async function () {
var d = await T.apiGet('/surfaces');
var found = d.data.some(function (s) { return s.id === surfaceId; });
T.assert(found, 're-enabled surface should appear in user nav list');
});
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/chat/disable (reject → 400)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/chat/disable');
T.assert(d._status === 400, 'disabling chat should return 400, got ' + d._status);
});
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/admin/disable (reject → 400)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/admin/disable');
T.assert(d._status === 400, 'disabling admin should return 400, got ' + d._status);
});
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/chat (core reject → 400)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'DELETE',
'/admin/surfaces/chat');
T.assert(d._status === 400, 'deleting core surface should return 400, got ' + d._status);
});
await T.test('crud', 'surfaces', 'GET /admin/surfaces/:id (not found → 404)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'GET',
'/admin/surfaces/nonexistent-surface-id');
T.assert(d._status === 404, 'nonexistent should return 404, got ' + d._status);
});
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/nonexistent/disable (404)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/nonexistent-surface-id/disable');
T.assert(d._status === 404, 'disable nonexistent should return 404, got ' + d._status);
});
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (missing manifest → 400)', async function () {
var zipBlob = buildTestZip({ 'readme.txt': 'no manifest here' });
var d;
try {
d = await T.apiUpload('/admin/surfaces/install', zipBlob, 'bad.surface');
T.assert(false, 'should have thrown');
} catch (e) {
T.assert(e.message.indexOf('400') !== -1, 'missing manifest should 400: ' + e.message);
}
});
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (core conflict → 409)', async function () {
var manifest = JSON.stringify({ id: 'chat', title: 'Evil Chat', route: '/s/chat' });
var zipBlob = buildTestZip({ 'manifest.json': manifest });
var d;
try {
d = await T.apiUpload('/admin/surfaces/install', zipBlob, 'chat.surface');
// If no throw, check status manually
T.assert(false, 'overwriting core surface should have thrown');
} catch (e) {
T.assert(e.message.indexOf('409') !== -1, 'core conflict should 409: ' + e.message);
}
});
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (reseed preserves enabled)', async function () {
// Surface was re-enabled above. Disable it, then re-install.
await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/' + surfaceId + '/disable');
// Re-install same ID
var manifest = JSON.stringify({
id: surfaceId,
title: 'ICD Test Surface v2',
route: '/s/' + surfaceId
});
var zipBlob = buildTestZip({ 'manifest.json': manifest });
await T.apiUpload('/admin/surfaces/install', zipBlob, surfaceId + '.surface');
// Verify title updated but enabled preserved as false
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
T.assert(d.title === 'ICD Test Surface v2', 'title should be updated after reseed');
T.assert(d.enabled === false, 'enabled should be preserved as false after reseed');
// Re-enable for cleanup
await T.authFetch(await T.getAuthToken(), 'PUT',
'/admin/surfaces/' + surfaceId + '/enable');
});
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/:id', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'DELETE', '/admin/surfaces/' + surfaceId);
T.assertStatus(d, 200, 'delete');
T.assert(d.deleted === true, 'should report deleted');
// Verify gone
var check = await T.authFetch(token, 'GET', '/admin/surfaces/' + surfaceId);
T.assert(check._status === 404, 'deleted surface should 404 on get');
surfaceId = null;
});
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/nonexistent (404)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'DELETE',
'/admin/surfaces/nonexistent-surface-id');
T.assert(d._status === 404, 'delete nonexistent should return 404, got ' + d._status);
});
}
};
})();

View File

@@ -0,0 +1,314 @@
/**
* ICD Test Runner — CRUD: Tasks
* Personal task lifecycle, validation (missing name, invalid cron,
* workflow type), webhook-triggered tasks, webhook secret auto-gen,
* admin task operations.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.tasks = async function (testTag) {
// ── Personal Tasks CRUD ──
var taskId = null;
var taskTriggerToken = null;
await T.test('crud', 'tasks', 'POST /tasks (create prompt task)', async function () {
var d = await T.apiPost('/tasks', {
name: testTag + '-task',
description: 'ICD integration test task',
task_type: 'prompt',
schedule: '@daily',
user_prompt: 'Summarize test data',
model_id: 'test-model',
timezone: 'UTC'
});
T.assertShape(d, T.S.taskFull, 'created task');
T.assert(d.task_type === 'prompt', 'task_type should be prompt');
T.assert(d.scope === 'personal', 'default scope should be personal');
T.assert(d.is_active === true, 'new task should be active');
T.assert(d.next_run_at !== null && d.next_run_at !== undefined, 'cron task should have next_run_at');
T.assert(d.max_tokens > 0, 'budget defaults should be applied');
taskId = d.id;
T.registerCleanup(function () { if (taskId) return T.safeDelete('/tasks/' + taskId); });
});
if (taskId) {
await T.test('crud', 'tasks', 'GET /tasks/:id (read)', async function () {
var d = await T.apiGet('/tasks/' + taskId);
T.assertShape(d, T.S.taskFull, 'task');
T.assert(d.id === taskId, 'id mismatch');
T.assert(d.name.indexOf(testTag) !== -1, 'name mismatch');
});
await T.test('crud', 'tasks', 'PUT /tasks/:id (update)', async function () {
var d = await T.apiPut('/tasks/' + taskId, {
name: testTag + '-task-updated',
user_prompt: 'Updated prompt'
});
T.assert(d.name === testTag + '-task-updated', 'name not updated');
});
await T.test('crud', 'tasks', 'GET /tasks (list mine)', async function () {
var d = await T.apiGet('/tasks');
T.assertHasKey(d, 'data', '/tasks');
T.assert(Array.isArray(d.data), 'data should be array');
T.assert(d.data.length >= 1, 'should have at least 1 task');
var found = d.data.some(function (t) { return t.id === taskId; });
T.assert(found, 'created task should be in list');
});
await T.test('crud', 'tasks', 'GET /tasks/:id/runs (list runs)', async function () {
var d = await T.apiGet('/tasks/' + taskId + '/runs');
T.assertHasKey(d, 'data', '/runs');
T.assert(Array.isArray(d.data), 'data should be array');
});
await T.test('crud', 'tasks', 'POST /tasks/:id/run (run now)', async function () {
var d = await T.apiPost('/tasks/' + taskId + '/run', {});
T.assertHasKey(d, 'scheduled', '/run');
T.assert(d.scheduled === true, 'should be scheduled');
});
await T.test('crud', 'tasks', 'POST /tasks/:id/kill (no active run → 404)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/tasks/' + taskId + '/kill');
// 200 = killed (if run started), 404 = no active run — both acceptable
T.assert(d._status === 200 || d._status === 404,
'expected 200 or 404, got ' + d._status);
});
await T.test('crud', 'tasks', 'PUT /tasks/:id (update schedule)', async function () {
var d = await T.apiPut('/tasks/' + taskId, { schedule: '@hourly' });
T.assert(d.schedule === '@hourly', 'schedule not updated');
});
await T.test('crud', 'tasks', 'PUT /tasks/:id (deactivate)', async function () {
var d = await T.apiPut('/tasks/' + taskId, { is_active: false });
T.assert(d.is_active === false, 'should be deactivated');
});
await T.test('crud', 'tasks', 'DELETE /tasks/:id', async function () {
await T.safeDelete('/tasks/' + taskId);
taskId = null;
});
}
// ── Task Validation Tests ──
await T.test('crud', 'tasks', 'POST /tasks (missing name → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/tasks', {
task_type: 'prompt', schedule: '@daily',
user_prompt: 'x', model_id: 'm'
});
T.assertStatus(d, 400, 'missing name');
});
await T.test('crud', 'tasks', 'POST /tasks (invalid cron → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/tasks', {
name: testTag + '-bad-cron', task_type: 'prompt',
schedule: 'not-a-cron', user_prompt: 'x', model_id: 'm'
});
T.assertStatus(d, 400, 'invalid cron');
});
await T.test('crud', 'tasks', 'POST /tasks (workflow type → 400 not implemented)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/tasks', {
name: testTag + '-wf', task_type: 'workflow',
schedule: '@daily', workflow_id: '00000000-0000-0000-0000-000000000001',
model_id: 'm'
});
T.assertStatus(d, 400, 'workflow type rejected');
});
// ── Webhook-Triggered Task + Trigger Endpoint ──
var webhookTaskId = null;
var webhookTriggerToken = null;
await T.test('crud', 'tasks', 'POST /tasks (create webhook task)', async function () {
var d = await T.apiPost('/tasks', {
name: testTag + '-webhook-task',
task_type: 'prompt',
schedule: 'webhook',
user_prompt: 'Process trigger data',
model_id: 'test-model'
});
T.assertShape(d, T.S.taskFull, 'webhook task');
T.assert(d.schedule === 'webhook', 'schedule should be webhook');
T.assert(d.trigger_token && d.trigger_token.length > 0, 'webhook task should have trigger_token');
T.assert(d.next_run_at === null || d.next_run_at === undefined, 'webhook task should have no next_run_at');
webhookTaskId = d.id;
webhookTriggerToken = d.trigger_token;
T.registerCleanup(function () { if (webhookTaskId) return T.safeDelete('/tasks/' + webhookTaskId); });
});
if (webhookTaskId && webhookTriggerToken) {
await T.test('crud', 'tasks', 'POST /hooks/t/:token (fire trigger → 202)', async function () {
var d = await T.publicPost('/hooks/t/' + webhookTriggerToken, {
build_id: 12345, status: 'failed', repo: 'icd-test'
});
T.assertStatus(d, 202, 'trigger');
T.assert(d.triggered === true, 'should be triggered');
T.assert(d.run_id && d.run_id.length > 0, 'should return run_id');
T.assert(d.task_id === webhookTaskId, 'task_id should match');
});
await T.test('crud', 'tasks', 'POST /hooks/t/:token (duplicate → 409)', async function () {
var d = await T.publicPost('/hooks/t/' + webhookTriggerToken, { test: true });
T.assertStatus(d, 409, 'duplicate trigger');
});
await T.test('crud', 'tasks', 'POST /hooks/t/bad-token (invalid → 404)', async function () {
var d = await T.publicPost('/hooks/t/nonexistent-token-value', { test: true });
T.assertStatus(d, 404, 'bad token');
});
// Deactivate then trigger → 410
await T.test('crud', 'tasks', 'PUT+POST deactivated webhook task → 410', async function () {
await T.apiPut('/tasks/' + webhookTaskId, { is_active: false });
var d = await T.publicPost('/hooks/t/' + webhookTriggerToken, { test: true });
T.assertStatus(d, 410, 'inactive trigger');
// Re-activate for cleanup
await T.apiPut('/tasks/' + webhookTaskId, { is_active: true });
});
await T.test('crud', 'tasks', 'GET /tasks/:id/runs (webhook task has queued run)', async function () {
var d = await T.apiGet('/tasks/' + webhookTaskId + '/runs');
T.assertHasKey(d, 'data', '/runs');
T.assert(Array.isArray(d.data), 'data should be array');
T.assert(d.data.length >= 1, 'webhook task should have at least 1 run from trigger');
T.assertShape(d.data[0], T.S.taskRun, 'run[0]');
});
await T.test('crud', 'tasks', 'DELETE /tasks/:id (webhook task)', async function () {
await T.safeDelete('/tasks/' + webhookTaskId);
webhookTaskId = null;
});
}
// ── Webhook Secret Auto-Generation ──
await T.test('crud', 'tasks', 'POST /tasks (webhook_url → auto webhook_secret)', async function () {
var d = await T.apiPost('/tasks', {
name: testTag + '-secret-task',
task_type: 'prompt', schedule: '@daily',
user_prompt: 'x', model_id: 'm',
webhook_url: 'https://example.com/hook'
});
T.assert(d.webhook_secret && d.webhook_secret.length > 0,
'webhook_secret should be auto-generated when webhook_url is set');
if (d.id) await T.safeDelete('/tasks/' + d.id);
});
// ── Admin Task Operations ──
if (T.user.role === 'admin') {
var adminTaskId = null;
await T.test('crud', 'tasks', 'POST /tasks (create for admin ops)', async function () {
var d = await T.apiPost('/tasks', {
name: testTag + '-admin-task',
task_type: 'prompt', schedule: '@daily',
user_prompt: 'admin test', model_id: 'm'
});
adminTaskId = d.id;
T.registerCleanup(function () { if (adminTaskId) return T.safeDelete('/tasks/' + adminTaskId); });
});
await T.test('crud', 'tasks', 'GET /admin/tasks (list all)', async function () {
var d = await T.apiGet('/admin/tasks');
T.assertHasKey(d, 'data', '/admin/tasks');
T.assert(Array.isArray(d.data), 'data should be array');
if (adminTaskId) {
var found = d.data.some(function (t) { return t.id === adminTaskId; });
T.assert(found, 'admin-created task should appear in admin list');
}
});
if (adminTaskId) {
await T.test('crud', 'tasks', 'POST /admin/tasks/:id/run (admin run)', async function () {
var d = await T.apiPost('/admin/tasks/' + adminTaskId + '/run', {});
T.assertHasKey(d, 'scheduled', '/admin/run');
T.assert(d.scheduled === true, 'should be scheduled');
});
await T.test('crud', 'tasks', 'POST /admin/tasks/:id/kill (admin kill)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/admin/tasks/' + adminTaskId + '/kill');
T.assert(d._status === 200 || d._status === 404,
'expected 200 or 404, got ' + d._status);
});
await T.test('crud', 'tasks', 'DELETE /admin/tasks/:id (admin delete)', async function () {
await T.safeDelete('/admin/tasks/' + adminTaskId);
adminTaskId = null;
});
}
}
// ── System functions (v0.28.6) ──
await T.test('crud', 'tasks', 'GET /admin/system-functions', async function () {
var d = await T.apiGet('/admin/system-functions');
T.assertHasKey(d, 'data', 'system-functions');
T.assert(Array.isArray(d.data), 'data should be array');
T.assert(d.data.length >= 4, 'should have at least 4 built-in functions, got ' + d.data.length);
var names = d.data.map(function(f) { return f.name; });
T.assert(names.indexOf('session_cleanup') >= 0, 'should include session_cleanup');
T.assert(names.indexOf('staleness_check') >= 0, 'should include staleness_check');
T.assert(names.indexOf('retention_sweep') >= 0, 'should include retention_sweep');
T.assert(names.indexOf('health_prune') >= 0, 'should include health_prune');
// Verify shape
T.assert(typeof d.data[0].name === 'string', 'name should be string');
T.assert(typeof d.data[0].description === 'string', 'description should be string');
});
var sysTaskId = null;
await T.test('crud', 'tasks', 'POST /tasks (create system task)', async function () {
var d = await T.apiPost('/tasks', {
name: testTag + '-system-task',
task_type: 'system',
system_function: 'health_prune',
schedule: '0 3 * * *',
scope: 'global',
});
T.assert(d.id, 'should return id');
T.assert(d.task_type === 'system', 'task_type should be system');
T.assert(d.system_function === 'health_prune', 'system_function should be health_prune');
sysTaskId = d.id;
T.registerCleanup(function () { if (sysTaskId) return T.safeDelete('/tasks/' + sysTaskId); });
});
if (sysTaskId) {
await T.test('crud', 'tasks', 'DELETE /tasks/:id (cleanup system task)', async function () {
await T.safeDelete('/tasks/' + sysTaskId);
sysTaskId = null;
});
}
await T.test('crud', 'tasks', 'POST /tasks (system task — invalid function → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/tasks', {
name: 'bad-func',
task_type: 'system',
system_function: 'nonexistent_func',
schedule: '0 3 * * *',
});
T.assertStatus(d, 400, 'invalid system function');
});
await T.test('crud', 'tasks', 'POST /tasks (system task — missing function → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/tasks', {
name: 'no-func',
task_type: 'system',
schedule: '0 3 * * *',
});
T.assertStatus(d, 400, 'missing system_function');
});
};
})();

View File

@@ -0,0 +1,281 @@
/**
* ICD Test Runner — CRUD: Teams
* Admin team lifecycle (create → read → update → members → team-scoped
* endpoints → team tasks → groups → grants → permissions → audit →
* usage → assignments → member remove → delete).
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.teams = async function (testTag) {
// ── Teams CRUD (admin — create team, exercise team-scoped endpoints) ──
if (T.user.role === 'admin') {
var teamId = null;
await T.test('crud', 'teams', 'POST /admin/teams (create)', async function () {
var d = await T.apiPost('/admin/teams', { name: testTag + '-team', description: 'ICD test team' });
T.assertShape(d, T.S.team, 'team');
teamId = d.id;
T.registerCleanup(function () { if (teamId) return T.safeDelete('/admin/teams/' + teamId); });
});
if (teamId) {
await T.test('crud', 'teams', 'GET /admin/teams/:id (read)', async function () {
var d = await T.apiGet('/admin/teams/' + teamId);
T.assertShape(d, T.S.team, 'team');
T.assert(d.id === teamId, 'id mismatch');
});
await T.test('crud', 'teams', 'PUT /admin/teams/:id (update)', async function () {
var d = await T.apiPut('/admin/teams/' + teamId, { name: testTag + '-team-updated' });
T.assert(typeof d === 'object', 'expected object');
});
// Add current user as team admin
await T.test('crud', 'teams', 'POST /admin/teams/:id/members (add self)', async function () {
var d = await T.apiPost('/admin/teams/' + teamId + '/members', { user_id: T.user.id, role: 'admin' });
T.assert(typeof d === 'object', 'expected object');
});
await T.test('crud', 'teams', 'GET /admin/teams/:id/members', async function () {
var d = await T.apiGet('/admin/teams/' + teamId + '/members');
T.assertHasKey(d, 'data', '/team-members');
T.assert(Array.isArray(d.data), 'data should be array');
T.assert(d.data.length >= 1, 'expected at least 1 member');
});
// Team-scoped endpoints (via team admin routes)
await T.test('crud', 'teams', 'GET /teams/:teamId/members (team route)', async function () {
var d = await T.apiGet('/teams/' + teamId + '/members');
T.assertHasKey(d, 'data', '/team-members');
});
// Member update + remove (need a second user — use admin self-membership)
await T.test('crud', 'teams', 'PUT /admin/teams/:id/members/:mid (update role)', async function () {
// Get own membership ID
var d = await T.apiGet('/admin/teams/' + teamId + '/members');
var me = d.data.find(function (m) { return m.user_id === T.user.id; });
T.assert(me, 'should find self in members');
// Update role to member then back to admin
var r = await T.apiPut('/admin/teams/' + teamId + '/members/' + me.id, { role: 'member' });
T.assert(typeof r === 'object', 'expected object');
// Restore admin role
await T.apiPut('/admin/teams/' + teamId + '/members/' + me.id, { role: 'admin' });
});
// Team groups listing
await T.test('crud', 'teams', 'GET /teams/:teamId/groups', async function () {
var d = await T.apiGet('/teams/' + teamId + '/groups');
T.assertHasKey(d, 'data', '/team-groups');
T.assert(Array.isArray(d.data), 'data should be array');
});
await T.test('crud', 'teams', 'GET /teams/:teamId/personas', async function () {
var d = await T.apiGet('/teams/' + teamId + '/personas');
T.assertHasKey(d, 'data', '/team-personas');
T.assert(Array.isArray(d.data), 'team personas should be array');
});
await T.test('crud', 'teams', 'GET /teams/:teamId/providers', async function () {
var d = await T.apiGet('/teams/' + teamId + '/providers');
T.assertHasKey(d, 'data', '/team-providers');
T.assert(Array.isArray(d.data), 'data should be array');
T.assertHasKey(d, 'allow_team_providers', '/team-providers');
});
await T.test('crud', 'teams', 'GET /teams/:teamId/models', async function () {
var d = await T.apiGet('/teams/' + teamId + '/models');
T.assertHasKey(d, 'models', '/team-models');
});
// ── Team Tasks CRUD ──
var teamTaskId = null;
await T.test('crud', 'teams', 'POST /teams/:teamId/tasks (create team task)', async function () {
var d = await T.apiPost('/teams/' + teamId + '/tasks', {
name: testTag + '-team-task',
task_type: 'prompt',
schedule: '@daily',
user_prompt: 'Team task test',
model_id: 'test-model'
});
T.assertShape(d, T.S.taskFull, 'team task');
T.assert(d.scope === 'team', 'team task scope should be team');
T.assert(d.team_id === teamId, 'team_id should match');
teamTaskId = d.id;
T.registerCleanup(function () { if (teamTaskId) return T.safeDelete('/teams/' + teamId + '/tasks/' + teamTaskId); });
});
await T.test('crud', 'teams', 'GET /teams/:teamId/tasks (list team tasks)', async function () {
var d = await T.apiGet('/teams/' + teamId + '/tasks');
T.assertHasKey(d, 'data', '/team-tasks');
T.assert(Array.isArray(d.data), 'data should be array');
if (teamTaskId) {
T.assert(d.data.length >= 1, 'should have at least 1 team task');
var found = d.data.some(function (t) { return t.id === teamTaskId; });
T.assert(found, 'created team task should be in list');
}
});
if (teamTaskId) {
await T.test('crud', 'teams', 'PUT /teams/:teamId/tasks/:id (update team task)', async function () {
var d = await T.apiPut('/teams/' + teamId + '/tasks/' + teamTaskId, {
name: testTag + '-team-task-updated'
});
T.assert(d.name === testTag + '-team-task-updated', 'name not updated');
});
await T.test('crud', 'teams', 'GET /teams/:teamId/tasks/:id/runs (team task runs)', async function () {
var d = await T.apiGet('/teams/' + teamId + '/tasks/' + teamTaskId + '/runs');
T.assertHasKey(d, 'data', '/team-task-runs');
T.assert(Array.isArray(d.data), 'data should be array');
});
await T.test('crud', 'teams', 'POST /teams/:teamId/tasks/:id/run (team task run)', async function () {
var d = await T.apiPost('/teams/' + teamId + '/tasks/' + teamTaskId + '/run', {});
T.assertHasKey(d, 'scheduled', '/team-run');
T.assert(d.scheduled === true, 'should be scheduled');
});
await T.test('crud', 'teams', 'POST /teams/:teamId/tasks/:id/kill (team task kill)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/teams/' + teamId + '/tasks/' + teamTaskId + '/kill');
T.assert(d._status === 200 || d._status === 404,
'expected 200 or 404, got ' + d._status);
});
await T.test('crud', 'teams', 'DELETE /teams/:teamId/tasks/:id (delete team task)', async function () {
await T.safeDelete('/teams/' + teamId + '/tasks/' + teamTaskId);
teamTaskId = null;
});
}
await T.test('crud', 'teams', 'GET /teams/:teamId/roles', async function () {
var d = await T.apiGet('/teams/' + teamId + '/roles');
T.assertHasKey(d, 'data', '/team-roles');
T.assert(typeof d.data === 'object', 'data should be object (role overrides map)');
});
await T.test('crud', 'teams', 'GET /teams/:teamId/audit', async function () {
var d = await T.apiGet('/teams/' + teamId + '/audit');
T.assertHasKey(d, 'data', '/team-audit');
T.assert(Array.isArray(d.data), 'data should be array');
T.assertHasKey(d, 'total', '/team-audit pagination');
T.assertHasKey(d, 'page', '/team-audit pagination');
});
await T.test('crud', 'teams', 'GET /teams/:teamId/audit/actions', async function () {
var d = await T.apiGet('/teams/' + teamId + '/audit/actions');
T.assertHasKey(d, 'actions', '/team-audit-actions');
T.assert(Array.isArray(d.actions), 'actions should be array');
});
await T.test('crud', 'teams', 'GET /teams/:teamId/usage', async function () {
var d = await T.apiGet('/teams/' + teamId + '/usage');
T.assertHasKey(d, 'totals', '/team-usage');
T.assertHasKey(d, 'results', '/team-usage');
});
await T.test('crud', 'teams', 'GET /teams/:teamId/assignments', async function () {
var d = await T.apiGet('/teams/' + teamId + '/assignments');
T.assert(typeof d === 'object', 'expected object');
});
// Admin groups CRUD within team context
var groupId = null;
await T.test('crud', 'teams', 'POST /admin/groups (create team group)', async function () {
var d = await T.apiPost('/admin/groups', { name: testTag + '-group', scope: 'team', team_id: teamId });
T.assertShape(d, T.S.group, 'group');
groupId = d.id;
T.registerCleanup(function () { if (groupId) return T.safeDelete('/admin/groups/' + groupId); });
});
if (groupId) {
await T.test('crud', 'teams', 'GET /admin/groups/:id', async function () {
var d = await T.apiGet('/admin/groups/' + groupId);
T.assertShape(d, T.S.group, 'group');
});
await T.test('crud', 'teams', 'PUT /admin/groups/:id (set permissions)', async function () {
var d = await T.apiPut('/admin/groups/' + groupId, {
permissions: ['model.use', 'kb.read', 'channel.create']
});
T.assert(typeof d === 'object', 'expected object');
});
await T.test('crud', 'teams', 'POST /admin/groups/:id/members (add self)', async function () {
var d = await T.apiPost('/admin/groups/' + groupId + '/members', { user_id: T.user.id });
T.assert(typeof d === 'object', 'expected object');
});
await T.test('crud', 'teams', 'GET /admin/groups/:id/members', async function () {
var d = await T.apiGet('/admin/groups/' + groupId + '/members');
T.assertHasKey(d, 'data', '/group-members');
T.assert(Array.isArray(d.data), 'data should be array');
});
await T.test('crud', 'teams', 'DELETE /admin/groups/:id/members/:uid', async function () {
await T.safeDelete('/admin/groups/' + groupId + '/members/' + T.user.id);
});
await T.test('crud', 'teams', 'DELETE /admin/groups/:id', async function () {
await T.safeDelete('/admin/groups/' + groupId);
groupId = null;
});
}
// Grants
await T.test('crud', 'teams', 'GET /admin/grants/persona/:id (404 = no grant)', async function () {
// No persona to test, just verify the endpoint responds
try {
var d = await T.apiGet('/admin/grants/persona/00000000-0000-0000-0000-000000000000');
T.assert(typeof d === 'object', 'expected object');
} catch (e) {
if (e.message && e.message.indexOf('404') !== -1) return; // expected
throw e;
}
});
// User permissions
await T.test('crud', 'teams', 'GET /admin/users/:id/permissions', async function () {
var d = await T.apiGet('/admin/users/' + T.user.id + '/permissions');
T.assertHasKey(d, 'permissions', '/user-permissions');
T.assert(Array.isArray(d.permissions), 'permissions should be array');
T.assertHasKey(d, 'user_id', '/user-permissions');
T.assertHasKey(d, 'groups', '/user-permissions contributing groups');
T.assert(Array.isArray(d.groups), 'groups should be array');
});
// List all permissions
await T.test('crud', 'teams', 'GET /admin/permissions', async function () {
var d = await T.apiGet('/admin/permissions');
T.assertHasKey(d, 'permissions', '/admin/permissions');
T.assert(Array.isArray(d.permissions), 'permissions should be array');
T.assert(d.permissions.length > 0, 'should have at least 1 permission');
});
// Member remove (remove self then re-add to keep admin for cleanup)
await T.test('crud', 'teams', 'DELETE /admin/teams/:id/members/:mid (remove)', async function () {
var d = await T.apiGet('/admin/teams/' + teamId + '/members');
var me = d.data.find(function (m) { return m.user_id === T.user.id; });
T.assert(me, 'should find self in members');
var r = await T.safeDelete('/admin/teams/' + teamId + '/members/' + me.id);
T.assert(typeof r === 'object' || r === undefined, 'expected object or void');
// Re-add self as admin for cleanup
await T.apiPost('/admin/teams/' + teamId + '/members', { user_id: T.user.id, role: 'admin' });
});
await T.test('crud', 'teams', 'DELETE /admin/teams/:id', async function () {
await T.safeDelete('/admin/teams/' + teamId);
teamId = null;
});
}
}
};
})();

View File

@@ -0,0 +1,403 @@
/**
* ICD Test Runner — CRUD: Workflows
* Admin workflow lifecycle (create → stages → activate → publish →
* start instance → advance → reject → delete), visitor E2E,
* assignment claim/complete lifecycle.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.workflows = async function (testTag) {
// ── Workflows CRUD (admin — requires workflow.create permission) ──
if (T.user.role === 'admin') {
var wfId = null;
var wfSlug = testTag.toLowerCase().replace(/[^a-z0-9-]/g, '-');
await T.test('crud', 'workflows', 'POST /workflows (create)', async function () {
var d = await T.apiPost('/workflows', {
name: testTag + '-workflow',
slug: wfSlug,
description: 'ICD integration test workflow',
entry_mode: 'team_only',
branding: { accent_color: '#2563eb', tagline: 'ICD Test' },
retention: { mode: 'archive', delete_after_days: 1 }
});
T.assertShape(d, T.S.workflow, 'workflow');
T.assert(d.slug === wfSlug, 'slug mismatch: expected ' + wfSlug + ', got ' + d.slug);
wfId = d.id;
T.registerCleanup(function () { if (wfId) return T.safeDelete('/workflows/' + wfId); });
});
if (wfId) {
await T.test('crud', 'workflows', 'GET /workflows/:id (read)', async function () {
var d = await T.apiGet('/workflows/' + wfId);
T.assertShape(d, T.S.workflow, 'workflow');
T.assert(d.id === wfId, 'id mismatch');
T.assert(d.name.indexOf(testTag) !== -1, 'name mismatch');
});
await T.test('crud', 'workflows', 'PATCH /workflows/:id (update)', async function () {
var d = await T.apiPatch('/workflows/' + wfId, {
description: 'Updated by ICD test runner',
webhook_url: 'https://example.com/hook'
});
T.assert(typeof d === 'object', 'expected object');
});
// ── Stages ──
var stageIds = [];
await T.test('crud', 'workflows', 'POST /workflows/:id/stages (create #1)', async function () {
var d = await T.apiPost('/workflows/' + wfId + '/stages', {
name: 'Intake',
ordinal: 0,
persona_id: null,
form_template: { fields: ['name', 'email'] },
history_mode: 'full',
auto_transition: false,
transition_rules: {}
});
T.assertShape(d, T.S.workflowStage, 'stage');
T.assert(d.name === 'Intake', 'name mismatch');
stageIds.push(d.id);
});
await T.test('crud', 'workflows', 'POST /workflows/:id/stages (create #2)', async function () {
var d = await T.apiPost('/workflows/' + wfId + '/stages', {
name: 'Review',
ordinal: 1,
persona_id: null,
form_template: {},
history_mode: 'summary',
auto_transition: false,
transition_rules: {}
});
T.assertShape(d, T.S.workflowStage, 'stage');
stageIds.push(d.id);
});
await T.test('crud', 'workflows', 'GET /workflows/:id/stages (list)', async function () {
var d = await T.apiGet('/workflows/' + wfId + '/stages');
T.assertHasKey(d, 'data', '/stages');
T.assert(Array.isArray(d.data), 'data should be array');
T.assert(d.data.length >= 2, 'expected at least 2 stages, got ' + d.data.length);
T.assertShape(d.data[0], T.S.workflowStage, 'stage[0]');
});
await T.test('crud', 'workflows', 'PUT /workflows/:id/stages/:sid (update)', async function () {
var d = await T.apiPut('/workflows/' + wfId + '/stages/' + stageIds[0], {
name: 'Intake (updated)',
ordinal: 0,
persona_id: null,
form_template: { fields: ['name', 'email', 'phone'] },
history_mode: 'full',
auto_transition: false,
transition_rules: {}
});
T.assert(typeof d === 'object', 'expected object');
});
if (stageIds.length >= 2) {
await T.test('crud', 'workflows', 'PATCH /workflows/:id/stages/reorder', async function () {
var reversed = stageIds.slice().reverse();
var d = await T.apiPatch('/workflows/' + wfId + '/stages/reorder', {
ordered_ids: reversed
});
T.assert(typeof d === 'object', 'expected object');
});
// Restore original order for subsequent tests
await T.apiPatch('/workflows/' + wfId + '/stages/reorder', { ordered_ids: stageIds });
}
// ── Activate workflow (required before starting instances) ──
await T.test('crud', 'workflows', 'PATCH /workflows/:id (activate)', async function () {
var d = await T.apiPatch('/workflows/' + wfId, { is_active: true });
T.assert(typeof d === 'object', 'expected object');
});
// ── Publish version ──
var versionNum = null;
await T.test('crud', 'workflows', 'POST /workflows/:id/publish', async function () {
var d = await T.apiPost('/workflows/' + wfId + '/publish', {});
T.assertShape(d, T.S.workflowVersion, 'version');
T.assertHasKey(d, 'snapshot', 'version');
versionNum = d.version_number;
});
if (versionNum !== null) {
await T.test('crud', 'workflows', 'GET /workflows/:id/versions/:v (read)', async function () {
var d = await T.apiGet('/workflows/' + wfId + '/versions/' + versionNum);
T.assertShape(d, T.S.workflowVersion, 'version');
T.assert(d.version_number === versionNum, 'version mismatch');
T.assertHasKey(d, 'snapshot', 'version');
});
await T.test('crud', 'workflows', 'POST /workflows/:id/publish (409 duplicate)', async function () {
try {
await T.apiPost('/workflows/' + wfId + '/publish', {});
throw new Error('expected 409 conflict, got success');
} catch (e) {
// 409 is the expected result
if (e.message.indexOf('409') === -1 && e.message.indexOf('conflict') === -1 &&
e.message.indexOf('already') === -1 && e.message.indexOf('expected 409') !== -1)
throw e;
// Otherwise: got the expected rejection
}
});
}
// ── Start instance → status → advance → reject ──
var instanceChannelId = null;
await T.test('crud', 'workflows', 'POST /workflows/:id/start (instance)', async function () {
var d = await T.apiPost('/workflows/' + wfId + '/start', {});
// Response may be: direct channel { id }, or { channel_id }, or { channel: { id } }, or { data: { id } }
var cid = d.id || d.channel_id || (d.channel && d.channel.id) || (d.data && d.data.id);
T.assert(cid, 'no channel ID in start response, got keys: ' + Object.keys(d).join(', '));
instanceChannelId = cid;
T.registerCleanup(function () { if (instanceChannelId) return T.safeDelete('/channels/' + instanceChannelId); });
});
if (instanceChannelId) {
await T.test('crud', 'workflows', 'GET /channels/:id/workflow/status', async function () {
var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status');
T.assertShape(d, T.S.workflowStatus, 'status');
T.assert(d.current_stage === 0, 'expected stage 0, got ' + d.current_stage);
T.assert(d.status === 'active', 'expected active, got ' + d.status);
});
await T.test('crud', 'workflows', 'POST /channels/:id/workflow/advance', async function () {
var d = await T.apiPost('/channels/' + instanceChannelId + '/workflow/advance', {
data: { name: 'ICD Test', email: 'test@icd.local' }
});
T.assert(typeof d === 'object', 'expected object');
});
await T.test('crud', 'workflows', 'GET workflow/status (after advance)', async function () {
var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status');
T.assertShape(d, T.S.workflowStatus, 'status');
// Should be at stage 1 (or completed if only 2 stages and auto-completed)
T.assert(d.current_stage >= 1 || d.status === 'completed',
'expected stage >= 1 or completed, got stage=' + d.current_stage + ' status=' + d.status);
});
// Reject back to stage 0 (only if still active and not at stage 0)
await T.test('crud', 'workflows', 'POST /channels/:id/workflow/reject', async function () {
var st = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status');
if (st.status === 'completed' || st.current_stage === 0) {
// Can't reject from stage 0 or completed — skip gracefully
return;
}
var d = await T.apiPost('/channels/' + instanceChannelId + '/workflow/reject', {
reason: 'ICD test: rejecting back to previous stage'
});
T.assert(typeof d === 'object', 'expected object');
});
// Verify reject went back
await T.test('crud', 'workflows', 'GET workflow/status (after reject)', async function () {
var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status');
T.assertShape(d, T.S.workflowStatus, 'status');
// Should be back at 0 or still active
T.assert(d.status === 'active' || d.status === 'completed',
'expected active or completed, got ' + d.status);
});
// Clean up instance channel before workflow delete (cascade may do it, but be explicit)
await T.test('crud', 'workflows', 'DELETE instance channel', async function () {
await T.safeDelete('/channels/' + instanceChannelId);
instanceChannelId = null;
});
}
// ── Stage delete ──
if (stageIds.length >= 2) {
await T.test('crud', 'workflows', 'DELETE /workflows/:id/stages/:sid', async function () {
await T.safeDelete('/workflows/' + wfId + '/stages/' + stageIds[stageIds.length - 1]);
stageIds.pop();
});
}
// ── Workflow delete ──
await T.test('crud', 'workflows', 'DELETE /workflows/:id', async function () {
await T.safeDelete('/workflows/' + wfId);
wfId = null;
});
}
}
// ── Visitor Workflow E2E ──────────────────────────────────
// Tests the anonymous session flow: create public_link workflow → visitor
// starts via entry API (no JWT) → sends messages via session cookie.
await (async function () {
var vwfId = null;
var vwfSlug = testTag + '-visitor-wf';
var visitorChannelId = null;
await T.test('crud', 'workflows', 'POST visitor workflow (public_link)', async function () {
var d = await T.apiPost('/workflows', {
name: testTag + ' Visitor WF',
slug: vwfSlug,
entry_mode: 'public_link'
});
T.assertShape(d, T.S.workflow, 'visitor workflow');
vwfId = d.id;
T.registerCleanup(function () { if (vwfId) return T.safeDelete('/workflows/' + vwfId); });
});
if (vwfId) {
await T.test('crud', 'workflows', 'POST visitor workflow stage', async function () {
await T.apiPost('/workflows/' + vwfId + '/stages', {
name: 'Intake', ordinal: 0, history_mode: 'full'
});
});
await T.test('crud', 'workflows', 'PATCH+publish visitor workflow', async function () {
await T.apiPatch('/workflows/' + vwfId, { is_active: true });
var pub = await T.apiPost('/workflows/' + vwfId + '/publish', {});
T.assert(pub._status === undefined || pub._status === 201 || pub.version_number,
'publish failed');
});
await T.test('crud', 'workflows', 'POST /workflow-entry (visitor start, no auth)', async function () {
var d = await T.publicPost('/workflow-entry/global/' + vwfSlug, {});
T.assert(d._status === 201, 'expected 201, got ' + d._status);
T.assertHasKey(d, 'channel_id', 'visitor start');
T.assertHasKey(d, 'session_id', 'visitor start');
T.assertHasKey(d, 'redirect_to', 'visitor start');
visitorChannelId = d.channel_id;
T.registerCleanup(function () {
if (visitorChannelId) return T.safeDelete('/channels/' + visitorChannelId);
});
});
if (visitorChannelId) {
await T.test('crud', 'workflows', 'POST /w/:id/messages (visitor session)', async function () {
var d = await T.sessionPost('/w/' + visitorChannelId + '/messages', {
content: 'Hello from visitor test',
role: 'user'
});
T.assert(d._status === 200 || d._status === 201, 'visitor message: got ' + d._status);
});
await T.test('crud', 'workflows', 'GET /w/:id/messages (visitor session)', async function () {
var d = await T.sessionGet('/w/' + visitorChannelId + '/messages');
T.assert(d._status === 200, 'visitor list messages: got ' + d._status);
var msgs = d.data || d.messages || d;
T.assert(Array.isArray(msgs), 'expected messages array');
T.assert(msgs.length > 0, 'no messages returned for visitor channel');
});
await T.test('crud', 'workflows', 'GET workflow/status (visitor instance)', async function () {
var d = await T.apiGet('/channels/' + visitorChannelId + '/workflow/status');
T.assertShape(d, T.S.workflowStatus, 'visitor workflow status');
T.assert(d.status === 'active', 'visitor workflow should be active');
});
}
await T.test('crud', 'workflows', 'DELETE visitor workflow', async function () {
if (visitorChannelId) { await T.safeDelete('/channels/' + visitorChannelId); visitorChannelId = null; }
if (vwfId) { await T.safeDelete('/workflows/' + vwfId); vwfId = null; }
});
}
})();
// ── Assignment Claim / Complete ────────────────────────────
// Tests the full assignment lifecycle: advance creates assignment →
// claim → double-claim 409 → complete → double-complete 409.
await (async function () {
var awfId = null;
var aTeamId = null;
var aChannelId = null;
var assignmentId = null;
await T.test('crud', 'workflows', 'assignment: create team + workflow', async function () {
var team = await T.apiPost('/admin/teams', {
name: testTag + '-assign-team', description: 'Assignment test'
});
aTeamId = team.id;
T.registerCleanup(function () { if (aTeamId) return T.safeDelete('/admin/teams/' + aTeamId); });
try { await T.apiPost('/admin/teams/' + aTeamId + '/members', { user_id: T.user.id, role: 'admin' }); }
catch (e) { /* auto-add */ }
var wf = await T.apiPost('/workflows', {
name: testTag + '-assign-wf', entry_mode: 'team_only'
});
awfId = wf.id;
T.registerCleanup(function () { if (awfId) return T.safeDelete('/workflows/' + awfId); });
await T.apiPost('/workflows/' + awfId + '/stages', {
name: 'Collect', ordinal: 0, history_mode: 'full'
});
await T.apiPost('/workflows/' + awfId + '/stages', {
name: 'Review', ordinal: 1, history_mode: 'full',
assignment_team_id: aTeamId
});
await T.apiPatch('/workflows/' + awfId, { is_active: true });
await T.apiPost('/workflows/' + awfId + '/publish', {});
});
if (awfId) {
await T.test('crud', 'workflows', 'assignment: start + advance to review stage', async function () {
var inst = await T.apiPost('/workflows/' + awfId + '/start', {});
T.assertHasKey(inst, 'channel_id', 'instance start');
aChannelId = inst.channel_id;
T.registerCleanup(function () { if (aChannelId) return T.safeDelete('/channels/' + aChannelId); });
var adv = await T.apiPost('/channels/' + aChannelId + '/workflow/advance', {
data: { intake: 'done' }
});
T.assert(adv.current_stage === 1, 'should be at stage 1 after advance');
});
await T.test('crud', 'workflows', 'assignment: GET /workflow-assignments/mine', async function () {
var d = await T.apiGet('/workflow-assignments/mine');
var arr = d.data || [];
T.assert(arr.length > 0, 'expected at least 1 assignment for my teams');
var ours = arr.find(function (a) { return a.team_id === aTeamId; });
T.assert(ours, 'no assignment found for test team');
assignmentId = ours.id;
});
if (assignmentId) {
await T.test('crud', 'workflows', 'assignment: POST claim', async function () {
var d = await T.apiPost('/workflow-assignments/' + assignmentId + '/claim', {});
T.assert(d.claimed === true, 'claim should return claimed=true');
});
await T.test('crud', 'workflows', 'assignment: POST claim (duplicate → 409)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'POST',
'/workflow-assignments/' + assignmentId + '/claim', {});
T.assert(d._status === 409, 'double claim should return 409, got ' + d._status);
});
await T.test('crud', 'workflows', 'assignment: POST complete', async function () {
var d = await T.apiPost('/workflow-assignments/' + assignmentId + '/complete', {});
T.assert(d.completed === true, 'complete should return completed=true');
});
await T.test('crud', 'workflows', 'assignment: POST complete (duplicate → 409)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'POST',
'/workflow-assignments/' + assignmentId + '/complete', {});
T.assert(d._status === 409, 'double complete should return 409, got ' + d._status);
});
}
await T.test('crud', 'workflows', 'assignment: cleanup', async function () {
if (aChannelId) { await T.safeDelete('/channels/' + aChannelId); aChannelId = null; }
if (awfId) { await T.safeDelete('/workflows/' + awfId); awfId = null; }
if (aTeamId) { await T.safeDelete('/admin/teams/' + aTeamId); aTeamId = null; }
});
}
})();
};
})();

View File

@@ -0,0 +1,198 @@
/**
* ICD Test Runner — CRUD: Workspaces
* Workspace lifecycle — create, read, update, file ops, stats, isolation, delete.
* Git credentials list envelope.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.workspaces = async function (testTag) {
// ── GET /workspaces — list envelope ──
await T.test('crud', 'workspaces', 'GET /workspaces (list envelope)', async function () {
var d = await T.apiGet('/workspaces');
T.assertHasKey(d, 'data', 'GET /workspaces');
T.assert(Array.isArray(d.data), 'data must be array');
});
// ── GET /git-credentials — list envelope ──
await T.test('crud', 'workspaces', 'GET /git-credentials (list envelope)', async function () {
var d = await T.apiGet('/git-credentials');
T.assertHasKey(d, 'data', 'GET /git-credentials');
T.assert(Array.isArray(d.data), 'data must be array');
});
// ── POST /git-credentials/generate — server-side keygen (v0.28.6) ──
var generatedKeyId = null;
await T.test('crud', 'workspaces', 'POST /git-credentials/generate', async function () {
var d = await T.apiPost('/git-credentials/generate', { name: 'ICD Test Key' });
T.assert(d.id, 'should return id');
T.assert(d.auth_type === 'ssh_key', 'auth_type should be ssh_key');
T.assert(d.public_key && d.public_key.length > 0, 'should return public_key');
T.assert(d.fingerprint && d.fingerprint.startsWith('SHA256:'), 'should return SHA256 fingerprint');
T.assert(!d.encrypted_data, 'must NOT expose encrypted_data');
T.assert(!d.nonce, 'must NOT expose nonce');
generatedKeyId = d.id;
T.registerCleanup(function () { if (generatedKeyId) return T.safeDelete('/git-credentials/' + generatedKeyId); });
});
if (generatedKeyId) {
await T.test('crud', 'workspaces', 'GET /git-credentials/:id/public-key', async function () {
var d = await T.apiGet('/git-credentials/' + generatedKeyId + '/public-key');
T.assertHasKey(d, 'public_key', 'public-key response');
T.assertHasKey(d, 'fingerprint', 'public-key response');
T.assert(d.public_key.length > 0, 'public_key should be non-empty');
});
await T.test('crud', 'workspaces', 'GET /git-credentials (list after generate)', async function () {
var d = await T.apiGet('/git-credentials');
var found = (d.data || []).find(function (c) { return c.id === generatedKeyId; });
T.assert(found, 'generated key should appear in list');
T.assert(found.public_key, 'list item should have public_key');
T.assert(found.fingerprint, 'list item should have fingerprint');
});
await T.test('crud', 'workspaces', 'DELETE /git-credentials/:id', async function () {
var d = await T.apiDelete('/git-credentials/' + generatedKeyId);
T.assert(d.deleted === true, 'should return deleted: true');
generatedKeyId = null;
});
}
await T.test('crud', 'workspaces', 'POST /git-credentials/generate (missing name → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/git-credentials/generate', {});
T.assertStatus(d, 400, 'missing name');
});
// ── Workspace CRUD ──
var wsId = null;
await T.test('crud', 'workspaces', 'POST /workspaces (create)', async function () {
var d = await T.apiPost('/workspaces', {
name: testTag + '-ws',
owner_type: 'user',
owner_id: T.user.id
});
T.assertShape(d, T.S.workspace, 'workspace');
T.assert(d.owner_id === T.user.id, 'owner_id mismatch');
T.assert(d.status === 'active', 'status should be active');
wsId = d.id;
T.registerCleanup(function () { if (wsId) return T.safeDelete('/workspaces/' + wsId); });
});
if (wsId) {
await T.test('crud', 'workspaces', 'GET /workspaces/:id (read)', async function () {
var d = await T.apiGet('/workspaces/' + wsId);
T.assertShape(d, T.S.workspace, 'workspace');
T.assert(d.id === wsId, 'id mismatch');
T.assert(d.name === testTag + '-ws', 'name mismatch');
});
// ── PATCH /workspaces/:id ──
await T.test('crud', 'workspaces', 'PATCH /workspaces/:id (update name)', async function () {
var d = await T.apiPatch('/workspaces/' + wsId, { name: testTag + '-ws-updated' });
T.assertShape(d, T.S.workspace, 'workspace-patched');
T.assert(d.name === testTag + '-ws-updated', 'name not updated');
});
// ── root_path must never be exposed ──
await T.test('crud', 'workspaces', 'GET /workspaces/:id (no root_path)', async function () {
var d = await T.apiGet('/workspaces/' + wsId);
T.assert(d.root_path === undefined, 'root_path must not be exposed in API');
});
// ── File operations ──
await T.test('crud', 'workspaces', 'GET /workspaces/:id/files (list root)', async function () {
var d = await T.apiGet('/workspaces/' + wsId + '/files?path=/');
T.assertHasKey(d, 'data', 'ListFiles envelope');
T.assert(Array.isArray(d.data), 'files should be array');
});
// mkdir
await T.test('crud', 'workspaces', 'POST /workspaces/:id/files/mkdir', async function () {
var d = await T.apiPost('/workspaces/' + wsId + '/files/mkdir?path=/testdir', {});
T.assert(d.ok === true, 'mkdir should return ok:true');
T.assert(d.path === '/testdir', 'path mismatch');
});
// Write a file then read it back
await T.test('crud', 'workspaces', 'PUT /workspaces/:id/files/write', async function () {
var d = await T.apiPut('/workspaces/' + wsId + '/files/write?path=' + encodeURIComponent('/test.txt'), {
content: 'Hello from ICD test runner'
});
T.assert(typeof d === 'object', 'expected object');
});
await T.test('crud', 'workspaces', 'GET /workspaces/:id/files/read', async function () {
var d = await T.apiGet('/workspaces/' + wsId + '/files/read?path=/test.txt');
T.assertHasKey(d, 'content', '/ws-file-read');
T.assert(d.content === 'Hello from ICD test runner', 'content mismatch');
});
await T.test('crud', 'workspaces', 'GET /workspaces/:id/stats', async function () {
var d = await T.apiGet('/workspaces/' + wsId + '/stats');
T.assert(typeof d === 'object', 'expected object');
T.assertHasKey(d, 'file_count', 'stats.file_count');
T.assertHasKey(d, 'total_bytes', 'stats.total_bytes');
});
await T.test('crud', 'workspaces', 'GET /workspaces/:id/index-status', async function () {
var d = await T.apiGet('/workspaces/' + wsId + '/index-status');
T.assertHasKey(d, 'workspace_id', 'index-status.workspace_id');
T.assertHasKey(d, 'status_counts', 'index-status.status_counts');
T.assertHasKey(d, 'total_chunks', 'index-status.total_chunks');
});
// ── File cleanup ──
await T.test('crud', 'workspaces', 'DELETE /workspaces/:id/files (file)', async function () {
try {
await T.safeDelete('/workspaces/' + wsId + '/files/delete?path=' + encodeURIComponent('/test.txt'));
} catch (e) {
if (e.message && e.message.indexOf('404') === -1) throw e;
}
});
await T.test('crud', 'workspaces', 'DELETE /workspaces/:id', async function () {
await T.safeDelete('/workspaces/' + wsId);
wsId = null;
});
}
// ── Workspace isolation: other user cannot access ──
// Create workspace as running admin user, try to access from fixture regular user
if (T.fixtures.ready) {
var fixtureUser = T.getFixtureUser('-user');
if (fixtureUser && fixtureUser.token) {
var isolWsId = null;
await T.test('crud', 'workspaces', 'isolation: create admin workspace', async function () {
var d = await T.apiPost('/workspaces', {
name: testTag + '-iso-ws',
owner_type: 'user',
owner_id: T.user.id
});
T.assertShape(d, T.S.workspace, 'workspace-iso');
isolWsId = d.id;
T.registerCleanup(function () { if (isolWsId) return T.safeDelete('/workspaces/' + isolWsId); });
});
if (isolWsId) {
await T.test('crud', 'workspaces', 'isolation: user cannot GET other workspace', async function () {
var d = await T.authFetch(fixtureUser.token, 'GET', '/workspaces/' + isolWsId);
T.assert(d._status === 403, 'expected 403, got ' + d._status);
});
// cleanup
await T.test('crud', 'workspaces', 'isolation: cleanup admin workspace', async function () {
await T.safeDelete('/workspaces/' + isolWsId);
isolWsId = null;
});
}
}
}
};
})();