Changeset 0.28.7 (#193)
This commit is contained in:
74
packages/icd-test-runner/css/main.css
Normal file
74
packages/icd-test-runner/css/main.css
Normal file
@@ -0,0 +1,74 @@
|
||||
/* ICD Test Runner — Surface Styles */
|
||||
|
||||
#extension-mount {
|
||||
font-family: var(--font, 'DM Sans', sans-serif);
|
||||
}
|
||||
|
||||
#extension-mount h1 {
|
||||
font-family: var(--font, 'DM Sans', sans-serif);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
#extension-mount table {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#extension-mount table td,
|
||||
#extension-mount table th {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
#extension-mount table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#extension-mount table tbody tr:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
}
|
||||
|
||||
/* Buttons — override small padding for our use */
|
||||
#extension-mount .btn-primary,
|
||||
#extension-mount .btn-secondary,
|
||||
#extension-mount .btn-ghost {
|
||||
font-size: 13px;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius, 8px);
|
||||
cursor: pointer;
|
||||
font-family: var(--font, 'DM Sans', sans-serif);
|
||||
font-weight: 600;
|
||||
transition: background var(--transition, 180ms ease), opacity var(--transition, 180ms ease);
|
||||
}
|
||||
|
||||
/* Fallback button styles if platform classes not loaded */
|
||||
#extension-mount button.btn-primary {
|
||||
background: var(--accent, #6c9fff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
#extension-mount button.btn-primary:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
#extension-mount button.btn-secondary {
|
||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
||||
color: var(--accent, #6c9fff);
|
||||
border: none;
|
||||
}
|
||||
#extension-mount button.btn-secondary:hover {
|
||||
background: var(--accent-dim, rgba(108,159,255,0.2));
|
||||
}
|
||||
|
||||
#extension-mount button.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-2, #9898a8);
|
||||
border: 1px solid var(--border, #2e2e35);
|
||||
}
|
||||
#extension-mount button.btn-ghost:hover {
|
||||
background: var(--bg-hover, #2a2a30);
|
||||
}
|
||||
|
||||
/* Status dot animation for running state */
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
110
packages/icd-test-runner/js/crud/_helpers.js
Normal file
110
packages/icd-test-runner/js/crud/_helpers.js
Normal 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' });
|
||||
};
|
||||
})();
|
||||
142
packages/icd-test-runner/js/crud/admin.js
Normal file
142
packages/icd-test-runner/js/crud/admin.js
Normal 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 }');
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
457
packages/icd-test-runner/js/crud/channels.js
Normal file
457
packages/icd-test-runner/js/crud/channels.js
Normal 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');
|
||||
});
|
||||
|
||||
};
|
||||
})();
|
||||
192
packages/icd-test-runner/js/crud/extensions.js
Normal file
192
packages/icd-test-runner/js/crud/extensions.js
Normal 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;
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
238
packages/icd-test-runner/js/crud/knowledge.js
Normal file
238
packages/icd-test-runner/js/crud/knowledge.js
Normal 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;
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
92
packages/icd-test-runner/js/crud/memory.js
Normal file
92
packages/icd-test-runner/js/crud/memory.js
Normal 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');
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
116
packages/icd-test-runner/js/crud/models.js
Normal file
116
packages/icd-test-runner/js/crud/models.js
Normal 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');
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
64
packages/icd-test-runner/js/crud/notes.js
Normal file
64
packages/icd-test-runner/js/crud/notes.js
Normal 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;
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
153
packages/icd-test-runner/js/crud/notifications.js
Normal file
153
packages/icd-test-runner/js/crud/notifications.js
Normal 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');
|
||||
});
|
||||
|
||||
};
|
||||
})();
|
||||
247
packages/icd-test-runner/js/crud/personas.js
Normal file
247
packages/icd-test-runner/js/crud/personas.js
Normal 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);
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
99
packages/icd-test-runner/js/crud/profile.js
Normal file
99
packages/icd-test-runner/js/crud/profile.js
Normal 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.
|
||||
|
||||
};
|
||||
})();
|
||||
194
packages/icd-test-runner/js/crud/projects.js
Normal file
194
packages/icd-test-runner/js/crud/projects.js
Normal 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 */ } }
|
||||
|
||||
};
|
||||
})();
|
||||
204
packages/icd-test-runner/js/crud/surfaces.js
Normal file
204
packages/icd-test-runner/js/crud/surfaces.js
Normal 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);
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
314
packages/icd-test-runner/js/crud/tasks.js
Normal file
314
packages/icd-test-runner/js/crud/tasks.js
Normal 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');
|
||||
});
|
||||
|
||||
};
|
||||
})();
|
||||
281
packages/icd-test-runner/js/crud/teams.js
Normal file
281
packages/icd-test-runner/js/crud/teams.js
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
403
packages/icd-test-runner/js/crud/workflows.js
Normal file
403
packages/icd-test-runner/js/crud/workflows.js
Normal 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; }
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
};
|
||||
})();
|
||||
198
packages/icd-test-runner/js/crud/workspaces.js
Normal file
198
packages/icd-test-runner/js/crud/workspaces.js
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
})();
|
||||
102
packages/icd-test-runner/js/fixtures.js
Normal file
102
packages/icd-test-runner/js/fixtures.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* ICD Test Runner — Fixtures
|
||||
* Creates and tears down test users, teams, and groups for AuthZ tests.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
|
||||
function genPassword() {
|
||||
var chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$';
|
||||
var pw = '';
|
||||
for (var i = 0; i < 16; i++) pw += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
return pw;
|
||||
}
|
||||
|
||||
T.provisionFixtures = async function () {
|
||||
var fixtures = T.fixtures;
|
||||
if (fixtures.ready) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Fixtures already provisioned — tear down first', 'warning');
|
||||
return;
|
||||
}
|
||||
if (T.user.role !== 'admin') {
|
||||
if (typeof UI !== 'undefined') UI.toast('Admin required to provision fixtures', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var tag = 'icd-' + Date.now();
|
||||
var errors = [];
|
||||
|
||||
var userDefs = [
|
||||
{ username: tag + '-user', display_name: 'ICD Test User', role: 'user' },
|
||||
{ username: tag + '-tadmin', display_name: 'ICD Test Team Admin', role: 'user' },
|
||||
{ username: tag + '-admin2', display_name: 'ICD Test Admin', role: 'admin' }
|
||||
];
|
||||
|
||||
for (var i = 0; i < userDefs.length; i++) {
|
||||
var def = userDefs[i];
|
||||
var pw = genPassword();
|
||||
try {
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: def.username, password: pw,
|
||||
email: def.username + '@example.com', role: def.role
|
||||
});
|
||||
try { await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true }); } catch (e) { /* may already be active */ }
|
||||
var login = await T.publicPost('/auth/login', { login: def.username, password: pw });
|
||||
fixtures.users.push({
|
||||
username: def.username, password: pw, display_name: def.display_name,
|
||||
role: def.role, id: created.id, token: login.access_token || ''
|
||||
});
|
||||
} catch (e) {
|
||||
errors.push('User ' + def.username + ': ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
var team = await T.apiPost('/admin/teams', { name: tag + '-team', description: 'ICD test team' });
|
||||
fixtures.team = { id: team.id, name: team.name };
|
||||
try { await T.apiPost('/admin/teams/' + team.id + '/members', { user_id: T.user.id, role: 'admin' }); } catch (e) { /* auto-add */ }
|
||||
var tadmin = fixtures.users.find(function (u) { return u.username.indexOf('-tadmin') !== -1; });
|
||||
if (tadmin) await T.apiPost('/admin/teams/' + team.id + '/members', { user_id: tadmin.id, role: 'admin' });
|
||||
var regUser = fixtures.users.find(function (u) { return u.username.indexOf('-user') !== -1 && u.username.indexOf('-tadmin') === -1; });
|
||||
if (regUser) await T.apiPost('/admin/teams/' + team.id + '/members', { user_id: regUser.id, role: 'member' });
|
||||
} catch (e) { errors.push('Team: ' + e.message); }
|
||||
|
||||
try {
|
||||
var grp = await T.apiPost('/admin/groups', { name: tag + '-group', scope: 'global' });
|
||||
fixtures.group = { id: grp.id, name: grp.name };
|
||||
await T.apiPut('/admin/groups/' + grp.id, { permissions: ['model.use', 'kb.read'] });
|
||||
var regUser2 = fixtures.users.find(function (u) { return u.username.indexOf('-user') !== -1 && u.username.indexOf('-tadmin') === -1; });
|
||||
if (regUser2) await T.apiPost('/admin/groups/' + grp.id + '/members', { user_id: regUser2.id });
|
||||
} catch (e) { errors.push('Group: ' + e.message); }
|
||||
|
||||
fixtures.ready = true;
|
||||
if (typeof T.renderFixtures === 'function') T.renderFixtures();
|
||||
|
||||
if (errors.length > 0) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Fixtures created with ' + errors.length + ' errors', 'warning');
|
||||
console.warn('Fixture errors:', errors);
|
||||
} else {
|
||||
if (typeof UI !== 'undefined') UI.toast('Fixtures provisioned (' + fixtures.users.length + ' users, team, group)', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
T.teardownFixtures = async function () {
|
||||
var fixtures = T.fixtures;
|
||||
if (!fixtures.ready) return;
|
||||
if (fixtures.group) { try { await T.safeDelete('/admin/groups/' + fixtures.group.id); } catch (e) { /* ok */ } }
|
||||
if (fixtures.team) { try { await T.safeDelete('/admin/teams/' + fixtures.team.id); } catch (e) { /* ok */ } }
|
||||
for (var i = fixtures.users.length - 1; i >= 0; i--) {
|
||||
try { await T.safeDelete('/admin/users/' + fixtures.users[i].id); } catch (e) { /* ok */ }
|
||||
}
|
||||
T.fixtures = { ready: false, users: [], team: null, group: null };
|
||||
if (typeof T.renderFixtures === 'function') T.renderFixtures();
|
||||
if (typeof UI !== 'undefined') UI.toast('Test fixtures torn down', 'info');
|
||||
};
|
||||
|
||||
T.getFixtureUser = function (suffix) {
|
||||
return T.fixtures.users.find(function (u) { return u.username.indexOf(suffix) !== -1; });
|
||||
};
|
||||
|
||||
})();
|
||||
413
packages/icd-test-runner/js/framework.js
Normal file
413
packages/icd-test-runner/js/framework.js
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* ICD Test Runner — Framework
|
||||
* DOM helpers, assertion library, ICD shapes, API wrappers, test harness.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
|
||||
// ─── DOM Helpers ────────────────────────────────────────────
|
||||
|
||||
T.esc = function (s) {
|
||||
var el = document.createElement('span');
|
||||
el.textContent = String(s);
|
||||
return el.innerHTML;
|
||||
};
|
||||
|
||||
T.$ = function (tag, attrs, children) {
|
||||
var el = document.createElement(tag);
|
||||
if (attrs) Object.keys(attrs).forEach(function (k) {
|
||||
if (k === 'className') el.className = attrs[k];
|
||||
else if (k === 'style' && typeof attrs[k] === 'object')
|
||||
Object.assign(el.style, attrs[k]);
|
||||
else if (k.indexOf('on') === 0)
|
||||
el.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
|
||||
else el.setAttribute(k, attrs[k]);
|
||||
});
|
||||
if (children) {
|
||||
if (!Array.isArray(children)) children = [children];
|
||||
children.forEach(function (c) {
|
||||
if (typeof c === 'string') el.appendChild(document.createTextNode(c));
|
||||
else if (c) el.appendChild(c);
|
||||
});
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
// ─── Assertion Library ──────────────────────────────────────
|
||||
|
||||
function assertType(val, type, path) {
|
||||
var actual = val === null ? 'null' : Array.isArray(val) ? 'array' : typeof val;
|
||||
if (type === 'array') {
|
||||
if (!Array.isArray(val)) return path + ': expected array, got ' + actual;
|
||||
} else if (type === 'uuid') {
|
||||
if (typeof val !== 'string' || val.length < 8) return path + ': expected uuid string';
|
||||
} else if (type === 'string?') {
|
||||
if (val !== null && val !== undefined && typeof val !== 'string')
|
||||
return path + ': expected string|null, got ' + actual;
|
||||
} else if (type === 'number?') {
|
||||
if (val !== null && val !== undefined && typeof val !== 'number')
|
||||
return path + ': expected number|null, got ' + actual;
|
||||
} else if (type === 'bool') {
|
||||
if (typeof val !== 'boolean') return path + ': expected boolean, got ' + actual;
|
||||
} else if (type === 'object') {
|
||||
if (typeof val !== 'object' || val === null || Array.isArray(val))
|
||||
return path + ': expected object, got ' + actual;
|
||||
} else if (type === 'any') {
|
||||
// always passes
|
||||
} else {
|
||||
if (typeof val !== type) return path + ': expected ' + type + ', got ' + actual;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateShape(obj, schema, prefix) {
|
||||
var errors = [];
|
||||
prefix = prefix || '$';
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
errors.push(prefix + ': expected object, got ' + (obj === null ? 'null' : typeof obj));
|
||||
return errors;
|
||||
}
|
||||
Object.keys(schema).forEach(function (key) {
|
||||
var spec = schema[key];
|
||||
var val = obj[key];
|
||||
var path = prefix + '.' + key;
|
||||
if (typeof spec === 'string') {
|
||||
if (spec.charAt(spec.length - 1) === '?') {
|
||||
if (val === undefined || val === null) return;
|
||||
spec = spec.slice(0, -1);
|
||||
} else if (val === undefined) {
|
||||
errors.push(path + ': missing required field');
|
||||
return;
|
||||
}
|
||||
var e = assertType(val, spec, path);
|
||||
if (e) errors.push(e);
|
||||
} else if (typeof spec === 'object' && spec._type) {
|
||||
if (spec._optional && (val === undefined || val === null)) return;
|
||||
if (val === undefined) { errors.push(path + ': missing required field'); return; }
|
||||
var e2 = assertType(val, spec._type, path);
|
||||
if (e2) errors.push(e2);
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
T.assert = function (condition, msg) {
|
||||
if (!condition) throw new Error(msg || 'assertion failed');
|
||||
};
|
||||
|
||||
T.assertShape = function (obj, schema, label) {
|
||||
var errs = validateShape(obj, schema, label || '$');
|
||||
if (errs.length) throw new Error(errs.join('; '));
|
||||
};
|
||||
|
||||
T.assertArrayOf = function (arr, schema, label) {
|
||||
T.assert(Array.isArray(arr), (label || '$') + ': expected array');
|
||||
if (arr.length > 0) T.assertShape(arr[0], schema, (label || '$') + '[0]');
|
||||
};
|
||||
|
||||
T.assertHasKey = function (obj, key, label) {
|
||||
T.assert(obj && obj.hasOwnProperty(key), (label || '$') + ': missing key "' + key + '"');
|
||||
};
|
||||
|
||||
T.assertEnum = function (val, allowed, path) {
|
||||
if (allowed.indexOf(val) === -1)
|
||||
throw new Error(path + ': "' + val + '" not in [' + allowed.join(', ') + ']');
|
||||
};
|
||||
|
||||
T.assertStatus = function (data, expected, label) {
|
||||
T.assert(data._status === expected,
|
||||
(label || '$') + ': expected HTTP ' + expected + ', got ' + data._status +
|
||||
(data.error ? ' (' + data.error + ')' : ''));
|
||||
};
|
||||
|
||||
// ─── ICD Shapes ─────────────────────────────────────────────
|
||||
|
||||
var S = T.S;
|
||||
|
||||
S.channel = { id: 'string', title: 'string', type: 'string', created_at: 'string', updated_at: 'string' };
|
||||
S.channelFull = {
|
||||
id: 'string', user_id: 'string', title: 'string', type: 'string',
|
||||
ai_mode: 'string?', topic: 'string?',
|
||||
description: 'string?', model: 'string?', provider_config_id: 'string?',
|
||||
system_prompt: 'string?', is_archived: 'bool', is_pinned: 'bool',
|
||||
folder: 'string?', folder_id: 'string?', project_id: 'string?', workspace_id: 'string?',
|
||||
tags: 'array', created_at: 'string', updated_at: 'string'
|
||||
};
|
||||
S.channelModel = { id: 'string', channel_id: 'string', model_id: 'string', is_default: 'bool', created_at: 'string' };
|
||||
S.message = { id: 'string', channel_id: 'string', role: 'string', content: 'string', created_at: 'string' };
|
||||
S.persona = { id: 'string', name: 'string', scope: 'string', created_at: 'string', updated_at: 'string' };
|
||||
S.note = { id: 'string', title: 'string', created_at: 'string', updated_at: 'string' };
|
||||
S.project = {
|
||||
id: 'string', name: 'string', scope: 'string', owner_id: 'string',
|
||||
is_archived: 'bool', created_at: 'string', updated_at: 'string',
|
||||
description: 'string?', color: 'string?', icon: 'string?',
|
||||
team_id: 'string?', workspace_id: 'string?', settings: 'object?',
|
||||
channel_count: 'number?', kb_count: 'number?', note_count: 'number?'
|
||||
};
|
||||
S.kb = {
|
||||
id: 'string', name: 'string', scope: 'string',
|
||||
embedding_config: 'object', document_count: 'number',
|
||||
chunk_count: 'number', total_bytes: 'number', status: 'string',
|
||||
created_at: 'string', updated_at: 'string'
|
||||
};
|
||||
S.folder = { id: 'string', name: 'string', created_at: 'string' };
|
||||
S.workspace = { id: 'string', name: 'string', owner_type: 'string', owner_id: 'string', status: 'string', created_at: 'string', updated_at: 'string' };
|
||||
S.gitCredSummary = { id: 'string', name: 'string', auth_type: 'string', created_at: 'string' };
|
||||
S.notification = { id: 'string', type: 'string', title: 'string', read: 'bool', created_at: 'string' };
|
||||
S.memory = { id: 'string', scope: 'string', owner_id: 'string', key: 'string', value: 'string', confidence: 'number', status: 'string', created_at: 'string', updated_at: 'string' };
|
||||
S.profile = { id: 'string', username: 'string', email: 'string', role: 'string', settings: 'object', created_at: 'string' };
|
||||
S.surface = { id: 'string', title: 'string', source: 'string', enabled: 'bool' };
|
||||
S.surfaceNav = { id: 'string', title: 'string', route: 'string' };
|
||||
S.surfaceAdmin = { id: 'string', title: 'string', manifest: 'object', enabled: 'bool', source: 'string', installed_at: 'string', updated_at: 'string' };
|
||||
S.providerConfig = { id: 'string', name: 'string', provider: 'string', scope: 'string' };
|
||||
S.safeConfig = { id: 'string', name: 'string', provider: 'string', is_active: 'bool', has_key: 'bool' };
|
||||
S.catalogModel = { model_id: 'string', provider: 'string' };
|
||||
S.modelEnabled = { id: 'string', model_id: 'string', display_name: 'string', model_type: 'string', source: 'string', provider_config_id: 'string', provider_name: 'string', provider_type: 'string', capabilities: 'object', scope: 'string', is_persona: 'bool', hidden: 'bool' };
|
||||
S.modelPreference = { id: 'string', user_id: 'string', model_id: 'string', provider_config_id: 'string', hidden: 'bool', sort_order: 'number', created_at: 'string', updated_at: 'string' };
|
||||
S.task = { id: 'string', name: 'string', task_type: 'string', schedule: 'string', is_active: 'bool', created_at: 'string' };
|
||||
S.taskFull = { id: 'string', owner_id: 'string', name: 'string', task_type: 'string', scope: 'string', schedule: 'string', timezone: 'string', is_active: 'bool', max_tokens: 'number', max_tool_calls: 'number', max_wall_clock: 'number', output_mode: 'string', run_count: 'number', created_at: 'string', updated_at: 'string' };
|
||||
S.taskRun = { id: 'string', task_id: 'string', status: 'string', started_at: 'string' };
|
||||
S.workflow = { id: 'string', name: 'string', slug: 'string', entry_mode: 'string', is_active: 'bool', created_at: 'string', updated_at: 'string' };
|
||||
S.workflowStage = { id: 'string', workflow_id: 'string', ordinal: 'number', name: 'string', history_mode: 'string', created_at: 'string' };
|
||||
S.workflowVersion = { id: 'string', workflow_id: 'string', version_number: 'number', created_at: 'string' };
|
||||
S.workflowStatus = { workflow_id: 'string', workflow_version: 'number', current_stage: 'number', status: 'string' };
|
||||
S.assignment = { id: 'string', channel_id: 'string', status: 'string', created_at: 'string' };
|
||||
S.team = { id: 'string', name: 'string', created_at: 'string?' };
|
||||
S.teamMember = { user_id: 'string', role: 'string' };
|
||||
S.group = { id: 'string', name: 'string' };
|
||||
S.extension = { id: 'string', title: 'string', type: 'string', version: 'string', tier: 'string', enabled: 'bool', is_system: 'bool', scope: 'string', source: 'string', installed_at: 'string', updated_at: 'string' };
|
||||
S.auditEntry = { id: 'string', action: 'string', created_at: 'string' };
|
||||
S.file = { id: 'string', filename: 'string', content_type: 'string', origin: 'string', created_at: 'string' };
|
||||
S.participant = { id: 'string', channel_id: 'string', participant_type: 'string', role: 'string', joined_at: 'string' };
|
||||
S.adminUser = { id: 'string', username: 'string', role: 'string', created_at: 'string' };
|
||||
S.loginResponse = { access_token: 'string', refresh_token: 'string' };
|
||||
|
||||
// ─── Test Harness ───────────────────────────────────────────
|
||||
|
||||
T.registerCleanup = function (fn) { T.cleanup.push(fn); };
|
||||
|
||||
T.runCleanup = async function () {
|
||||
for (var i = T.cleanup.length - 1; i >= 0; i--) {
|
||||
try { await T.cleanup[i](); } catch (e) { /* best effort */ }
|
||||
}
|
||||
T.cleanup = [];
|
||||
};
|
||||
|
||||
T.test = async function (tier, domain, name, fn) {
|
||||
var t0 = performance.now();
|
||||
var entry = { tier: tier, domain: domain, name: name, status: 'pass', duration: 0, detail: '' };
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
if (e && e._skip) {
|
||||
entry.status = 'skip';
|
||||
entry.detail = String(e.message || 'skipped');
|
||||
} else {
|
||||
entry.status = 'fail';
|
||||
entry.detail = String(e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
entry.duration = Math.round(performance.now() - t0);
|
||||
T.results.push(entry);
|
||||
if (typeof T.renderProgress === 'function') T.renderProgress();
|
||||
return entry;
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip a test with a reason. Call inside a T.test() fn body.
|
||||
* Skipped tests appear in results as status='skip' — not pass, not fail.
|
||||
* @param {string} reason — why this test was skipped
|
||||
*/
|
||||
T.skip = function (reason) {
|
||||
var e = new Error(reason || 'skipped');
|
||||
e._skip = true;
|
||||
throw e;
|
||||
};
|
||||
|
||||
// ─── API Wrappers ───────────────────────────────────────────
|
||||
// API._get/_post/_put already prepend __BASE__. No base prefix here.
|
||||
|
||||
T.apiGet = async function (path) { return await API._get('/api/v1' + path); };
|
||||
T.apiPost = async function (path, body) { return await API._post('/api/v1' + path, body); };
|
||||
T.apiPut = async function (path, body) { return await API._put('/api/v1' + path, body); };
|
||||
|
||||
T.apiPatch = async function (path, body) {
|
||||
if (typeof API._patch === 'function') return await API._patch('/api/v1' + path, body);
|
||||
return await API._put('/api/v1' + path, body);
|
||||
};
|
||||
|
||||
// ─── Token Capture ──────────────────────────────────────────
|
||||
|
||||
var _capturedToken = '';
|
||||
|
||||
T.captureAuthToken = async function () {
|
||||
if (_capturedToken) return _capturedToken;
|
||||
var origFetch = window.fetch;
|
||||
window.fetch = function (url, opts) {
|
||||
if (!_capturedToken && opts && opts.headers) {
|
||||
var auth = '';
|
||||
if (opts.headers instanceof Headers) {
|
||||
auth = opts.headers.get('Authorization') || '';
|
||||
} else {
|
||||
auth = opts.headers['Authorization'] || opts.headers.authorization || '';
|
||||
}
|
||||
if (typeof auth === 'string' && auth.indexOf('Bearer ') === 0) {
|
||||
_capturedToken = auth.slice(7);
|
||||
}
|
||||
}
|
||||
return origFetch.apply(this, arguments);
|
||||
};
|
||||
try { await API._get('/api/v1/health'); } catch (e) { /* swallow */ }
|
||||
window.fetch = origFetch;
|
||||
return _capturedToken;
|
||||
};
|
||||
|
||||
function getAuthTokenFromStorage() {
|
||||
var env = (T.base || '').replace(/^\//, '') || 'default';
|
||||
var keys = ['sb_auth_' + env, 'sb_auth', 'sb_token'];
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
try {
|
||||
var raw = localStorage.getItem(keys[i]);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed) {
|
||||
var tok = parsed.access_token || parsed.token || parsed.accessToken || '';
|
||||
if (tok) return tok;
|
||||
}
|
||||
} catch (e2) { /* not JSON */ }
|
||||
if (raw.length > 20 && raw.indexOf('{') !== 0) return raw;
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
T.getAuthToken = async function () {
|
||||
return _capturedToken || (await T.captureAuthToken()) || getAuthTokenFromStorage();
|
||||
};
|
||||
|
||||
// ─── DELETE (needs raw fetch fallback) ──────────────────────
|
||||
|
||||
T.apiDelete = async function (path) {
|
||||
if (typeof API._delete === 'function') return await API._delete('/api/v1' + path);
|
||||
var token = await T.getAuthToken();
|
||||
var resp = await fetch(T.base + '/api/v1' + path, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (resp.status === 204 || resp.status === 200) {
|
||||
var text = await resp.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
throw new Error('DELETE ' + path + ' → ' + resp.status);
|
||||
};
|
||||
|
||||
T.safeDelete = async function (path, retries) {
|
||||
retries = retries || 2;
|
||||
for (var attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await T.apiDelete(path);
|
||||
} catch (e) {
|
||||
// On 502/503, wait and retry (proxy may be recovering)
|
||||
if (attempt < retries && e.message && (e.message.indexOf('502') !== -1 || e.message.indexOf('503') !== -1)) {
|
||||
await new Promise(function (r) { setTimeout(r, 500 * (attempt + 1)); });
|
||||
continue;
|
||||
}
|
||||
// Last resort: try without auth header (some DELETE endpoints accept this)
|
||||
try {
|
||||
var resp = await fetch(T.base + '/api/v1' + path, {
|
||||
method: 'DELETE', credentials: 'same-origin'
|
||||
});
|
||||
if (resp.ok || resp.status === 204) return {};
|
||||
} catch (e2) { /* give up */ }
|
||||
// Swallow cleanup errors on final attempt — don't let cleanup failures mask test results
|
||||
if (attempt >= retries) {
|
||||
console.warn('[ICD] cleanup failed: DELETE ' + path + ' — ' + e.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
// ── File Upload (multipart/form-data) ──────────────────────
|
||||
|
||||
T.apiUpload = async function (path, file, filename) {
|
||||
var token = await T.getAuthToken();
|
||||
var fd = new FormData();
|
||||
fd.append('file', file, filename || 'test.txt');
|
||||
var resp = await fetch(T.base + '/api/v1' + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
credentials: 'same-origin',
|
||||
body: fd
|
||||
});
|
||||
var text = await resp.text();
|
||||
var data;
|
||||
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
|
||||
data._status = resp.status;
|
||||
if (!resp.ok) throw new Error('Upload ' + path + ' → ' + resp.status + (data.error ? ': ' + data.error : ''));
|
||||
return data;
|
||||
};
|
||||
|
||||
// ─── Auth Fetch (explicit token for test users) ─────────────
|
||||
|
||||
T.authFetch = async function (token, method, path, body) {
|
||||
var opts = {
|
||||
method: method,
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
};
|
||||
if (body !== undefined && method !== 'GET') opts.body = JSON.stringify(body);
|
||||
var resp = await fetch(T.base + '/api/v1' + path, opts);
|
||||
var text = await resp.text();
|
||||
var data;
|
||||
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
|
||||
data._status = resp.status;
|
||||
return data;
|
||||
};
|
||||
|
||||
T.publicPost = async function (path, body) {
|
||||
var resp = await fetch(T.base + '/api/v1' + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
var text = await resp.text();
|
||||
var data;
|
||||
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
|
||||
data._status = resp.status;
|
||||
return data;
|
||||
};
|
||||
|
||||
// Session-scoped fetch: uses cookies only (no JWT). For visitor workflow tests.
|
||||
T.sessionGet = async function (path) {
|
||||
var resp = await fetch(T.base + '/api/v1' + path, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
var text = await resp.text();
|
||||
var data;
|
||||
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
|
||||
data._status = resp.status;
|
||||
return data;
|
||||
};
|
||||
|
||||
T.sessionPost = async function (path, body) {
|
||||
var resp = await fetch(T.base + '/api/v1' + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
var text = await resp.text();
|
||||
var data;
|
||||
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
|
||||
data._status = resp.status;
|
||||
return data;
|
||||
};
|
||||
|
||||
})();
|
||||
114
packages/icd-test-runner/js/main.js
Normal file
114
packages/icd-test-runner/js/main.js
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* ICD Test Runner — Entry Point / Module Loader
|
||||
*
|
||||
* Creates the shared ICD namespace and loads modules in dependency order.
|
||||
* Each module is an IIFE that attaches to window.ICD.
|
||||
*
|
||||
* Load order:
|
||||
* 1. framework.js — DOM helpers, assertions, shapes, API wrappers, test harness
|
||||
* 2. fixtures.js — Test user/team/group provisioning
|
||||
* 3. tier-smoke.js — Read-only GET tests
|
||||
* 4. crud/_helpers.js — Shared CRUD utilities (ZIP builder)
|
||||
* 5. crud/*.js — Per-group CRUD test modules (register on T.crud)
|
||||
* 6. tier-crud.js — CRUD orchestrator (calls each group)
|
||||
* 7. tier-authz.js — Permission boundary tests
|
||||
* 8. tier-security.js — Adversarial red-team tests (auth, cross-tenant, input validation)
|
||||
* 9. tier-providers.js — Three-tier provider CRUD + live completions
|
||||
* 10. ui.js — Render functions, export, provider setup panel
|
||||
* 11. (this file) — Boot
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var mount = document.getElementById('extension-mount');
|
||||
if (!mount) return;
|
||||
|
||||
// ─── Shared Namespace ───────────────────────────────────────
|
||||
window.ICD = {
|
||||
mount: mount,
|
||||
manifest: window.__MANIFEST__ || {},
|
||||
user: window.__USER__ || {},
|
||||
base: window.__BASE__ || '',
|
||||
|
||||
// State (populated by framework.js)
|
||||
results: [],
|
||||
cleanup: [],
|
||||
running: false,
|
||||
|
||||
// DOM refs (populated by ui.js)
|
||||
el: {},
|
||||
|
||||
// Module slots (populated by each module)
|
||||
S: {}, // shapes
|
||||
crud: {}, // CRUD group functions
|
||||
fixtures: { ready: false, users: [], team: null, group: null },
|
||||
providerSetup: { provider: 'openai', apiKey: '', endpoint: '', configured: false }
|
||||
};
|
||||
|
||||
// ─── Script Loader ──────────────────────────────────────────
|
||||
var surfaceId = window.ICD.manifest.id || 'icd-test-runner';
|
||||
var assetBase = '/surfaces/' + surfaceId + '/js/';
|
||||
|
||||
// In split-deployment (nginx path routing), assets may be under __BASE__
|
||||
if (window.ICD.base) {
|
||||
assetBase = window.ICD.base + assetBase;
|
||||
}
|
||||
|
||||
var modules = [
|
||||
'framework.js',
|
||||
'fixtures.js',
|
||||
'tier-smoke.js',
|
||||
// CRUD helpers + per-group modules (order among groups doesn't matter)
|
||||
'crud/_helpers.js',
|
||||
'crud/channels.js',
|
||||
'crud/models.js',
|
||||
'crud/notes.js',
|
||||
'crud/projects.js',
|
||||
'crud/knowledge.js',
|
||||
'crud/workspaces.js',
|
||||
'crud/profile.js',
|
||||
'crud/notifications.js',
|
||||
'crud/memory.js',
|
||||
'crud/admin.js',
|
||||
'crud/workflows.js',
|
||||
'crud/tasks.js',
|
||||
'crud/teams.js',
|
||||
'crud/personas.js',
|
||||
'crud/extensions.js',
|
||||
'crud/surfaces.js',
|
||||
// Orchestrator (must come after all crud/*.js)
|
||||
'tier-crud.js',
|
||||
'tier-authz.js',
|
||||
'tier-security.js',
|
||||
'tier-providers.js',
|
||||
'tier-sdk.js',
|
||||
'ui.js'
|
||||
];
|
||||
|
||||
var loaded = 0;
|
||||
|
||||
function loadNext() {
|
||||
if (loaded >= modules.length) {
|
||||
boot();
|
||||
return;
|
||||
}
|
||||
var script = document.createElement('script');
|
||||
script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0');
|
||||
script.onload = function () { loaded++; loadNext(); };
|
||||
script.onerror = function () {
|
||||
console.error('[ICD] Failed to load: ' + modules[loaded]);
|
||||
loaded++; loadNext(); // continue loading remaining modules
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (typeof window.ICD.renderShell === 'function') {
|
||||
window.ICD.renderShell();
|
||||
} else {
|
||||
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">ICD Test Runner failed to load modules. Check console.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
loadNext();
|
||||
})();
|
||||
228
packages/icd-test-runner/js/tier-authz.js
Normal file
228
packages/icd-test-runner/js/tier-authz.js
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* ICD Test Runner — AuthZ Tier
|
||||
* Permission boundary tests: cross-user isolation, role enforcement.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
|
||||
T.runAuthz = async function () {
|
||||
if (!T.fixtures.ready) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
var testUser = T.getFixtureUser('-user');
|
||||
var teamAdmin = T.getFixtureUser('-tadmin');
|
||||
var testAdmin = T.getFixtureUser('-admin2');
|
||||
|
||||
if (!testUser || !testUser.token) {
|
||||
T.results.push({ tier: 'authz', domain: 'setup', name: 'SKIP', status: 'fail', duration: 0,
|
||||
detail: 'Test user not provisioned or login failed (no token)' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Regular user MUST be denied admin routes ──
|
||||
|
||||
var adminGets = [
|
||||
'/admin/stats', '/admin/users', '/admin/settings',
|
||||
'/admin/configs', '/admin/models', '/admin/personas',
|
||||
'/admin/teams', '/admin/groups', '/admin/surfaces',
|
||||
'/admin/providers/health', '/admin/routing/policies',
|
||||
'/admin/permissions', '/admin/roles', '/admin/vault/status',
|
||||
'/admin/storage/status', '/admin/audit', '/admin/pricing',
|
||||
'/admin/tasks', '/admin/projects', '/admin/channels/archived',
|
||||
'/admin/extensions'
|
||||
];
|
||||
|
||||
for (var ai = 0; ai < adminGets.length; ai++) {
|
||||
await (async function (path) {
|
||||
await T.test('authz', 'admin-deny', 'user → GET ' + path + ' (expect 403)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', path);
|
||||
T.assert(d._status === 403 || d._status === 401,
|
||||
'expected 403/401 for non-admin, got ' + d._status);
|
||||
});
|
||||
})(adminGets[ai]);
|
||||
}
|
||||
|
||||
// ── Regular user MUST NOT be able to create admin resources ──
|
||||
|
||||
await T.test('authz', 'admin-deny', 'user → POST /admin/users (expect 403)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/users', {
|
||||
username: 'should-not-exist', password: 'x', email: 'no@no.no', role: 'user'
|
||||
});
|
||||
T.assert(d._status === 403 || d._status === 401,
|
||||
'expected 403/401, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('authz', 'admin-deny', 'user → POST /admin/teams (expect 403)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/teams', {
|
||||
name: 'should-not-exist'
|
||||
});
|
||||
T.assert(d._status === 403 || d._status === 401,
|
||||
'expected 403/401, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('authz', 'admin-deny', 'user → POST /admin/groups (expect 403)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/groups', {
|
||||
name: 'should-not-exist', scope: 'global'
|
||||
});
|
||||
T.assert(d._status === 403 || d._status === 401,
|
||||
'expected 403/401, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('authz', 'admin-deny', 'user → POST /admin/surfaces/install (expect 403)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/surfaces/install', {});
|
||||
T.assert(d._status === 403 || d._status === 401 || d._status === 400,
|
||||
'expected 403/401/400, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('authz', 'admin-deny', 'user → PUT /admin/users/:id/role (expect 403)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'PUT', '/admin/users/' + testUser.id + '/role', { role: 'admin' });
|
||||
T.assert(d._status === 403 || d._status === 401,
|
||||
'CRITICAL: non-admin was able to escalate role! got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('authz', 'admin-deny', 'user → POST /admin/users/:id/reset-password (expect 403)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/users/' + testUser.id + '/reset-password', { new_password: 'hacked' });
|
||||
T.assert(d._status === 403 || d._status === 401,
|
||||
'CRITICAL: non-admin was able to reset passwords! got ' + d._status);
|
||||
});
|
||||
|
||||
// ── Regular user SHOULD be able to access their own stuff ──
|
||||
|
||||
await T.test('authz', 'user-allow', 'user → GET /profile (expect 200)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/profile');
|
||||
T.assertStatus(d, 200, 'profile');
|
||||
T.assert(d.username === testUser.username, 'username mismatch');
|
||||
});
|
||||
|
||||
await T.test('authz', 'user-allow', 'user → GET /channels (expect 200)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/channels');
|
||||
T.assertStatus(d, 200, 'channels');
|
||||
});
|
||||
|
||||
await T.test('authz', 'user-allow', 'user → GET /models/enabled (expect 200)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/models/enabled');
|
||||
T.assertStatus(d, 200, 'models');
|
||||
});
|
||||
|
||||
await T.test('authz', 'user-allow', 'user → GET /surfaces (expect 200)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/surfaces');
|
||||
T.assertStatus(d, 200, 'surfaces');
|
||||
});
|
||||
|
||||
await T.test('authz', 'user-allow', 'user → GET /notifications/unread-count (expect 200)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/notifications/unread-count');
|
||||
T.assertStatus(d, 200, 'unread-count');
|
||||
});
|
||||
|
||||
await T.test('authz', 'user-allow', 'user → GET /teams/mine (expect 200)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/teams/mine');
|
||||
T.assertStatus(d, 200, 'teams/mine');
|
||||
});
|
||||
|
||||
await T.test('authz', 'user-allow', 'user → GET /groups/mine (expect 200)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/groups/mine');
|
||||
T.assertStatus(d, 200, 'groups/mine');
|
||||
});
|
||||
|
||||
// ── Permission-gated operations ──
|
||||
|
||||
await T.test('authz', 'perm-gate', 'user → POST /workflows (expect 403, no workflow.create)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/workflows', {
|
||||
name: 'should-not-exist', slug: 'should-not-exist', entry_mode: 'team_only'
|
||||
});
|
||||
T.assert(d._status === 403 || d._status === 401,
|
||||
'expected 403 (no workflow.create), got ' + d._status);
|
||||
});
|
||||
|
||||
// ── Team admin can manage their team but NOT platform admin ──
|
||||
|
||||
if (teamAdmin && teamAdmin.token && T.fixtures.team) {
|
||||
await T.test('authz', 'team-scope', 'teamAdmin → GET /teams/:id/members (expect 200)', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'GET', '/teams/' + T.fixtures.team.id + '/members');
|
||||
T.assertStatus(d, 200, 'team-members');
|
||||
});
|
||||
|
||||
await T.test('authz', 'team-scope', 'teamAdmin → GET /teams/:id/personas (expect 200)', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'GET', '/teams/' + T.fixtures.team.id + '/personas');
|
||||
T.assertStatus(d, 200, 'team-personas');
|
||||
});
|
||||
|
||||
await T.test('authz', 'team-scope', 'teamAdmin → GET /teams/:id/providers (expect 200)', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'GET', '/teams/' + T.fixtures.team.id + '/providers');
|
||||
T.assertStatus(d, 200, 'team-providers');
|
||||
});
|
||||
|
||||
await T.test('authz', 'team-scope', 'teamAdmin → GET /admin/stats (expect 403)', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'GET', '/admin/stats');
|
||||
T.assert(d._status === 403 || d._status === 401,
|
||||
'team admin should not have platform admin, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('authz', 'team-scope', 'teamAdmin → PUT /admin/users/:id/role (expect 403)', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'PUT', '/admin/users/' + teamAdmin.id + '/role', { role: 'admin' });
|
||||
T.assert(d._status === 403 || d._status === 401,
|
||||
'CRITICAL: team admin was able to escalate to platform admin! got ' + d._status);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Second admin CAN access admin routes ──
|
||||
|
||||
if (testAdmin && testAdmin.token) {
|
||||
await T.test('authz', 'admin-allow', 'admin2 → GET /admin/stats (expect 200)', async function () {
|
||||
var d = await T.authFetch(testAdmin.token, 'GET', '/admin/stats');
|
||||
T.assertStatus(d, 200, 'admin-stats');
|
||||
});
|
||||
|
||||
await T.test('authz', 'admin-allow', 'admin2 → GET /admin/users (expect 200)', async function () {
|
||||
var d = await T.authFetch(testAdmin.token, 'GET', '/admin/users');
|
||||
T.assertStatus(d, 200, 'admin-users');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Expired/invalid token ──
|
||||
|
||||
await T.test('authz', 'token', 'bogus token → GET /profile (expect 401)', async function () {
|
||||
var d = await T.authFetch('this-is-not-a-valid-token', 'GET', '/profile');
|
||||
T.assert(d._status === 401, 'expected 401 for bogus token, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('authz', 'token', 'empty token → GET /profile (expect 401)', async function () {
|
||||
var d = await T.authFetch('', 'GET', '/profile');
|
||||
T.assert(d._status === 401, 'expected 401 for empty token, got ' + d._status);
|
||||
});
|
||||
|
||||
// ── Cross-user resource isolation ──
|
||||
|
||||
var userChannel = null;
|
||||
await T.test('authz', 'isolation', 'admin creates private channel', async function () {
|
||||
var d = await T.apiPost('/channels', { title: 'authz-private-' + Date.now(), type: 'direct' });
|
||||
T.assertHasKey(d, 'id', 'channel');
|
||||
userChannel = d.id;
|
||||
T.registerCleanup(function () { if (userChannel) return T.safeDelete('/channels/' + userChannel); });
|
||||
});
|
||||
|
||||
if (userChannel && testUser.token) {
|
||||
await T.test('authz', 'isolation', 'user → GET /channels/:id (expect 403/404)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/channels/' + userChannel);
|
||||
T.assert(d._status === 403 || d._status === 404,
|
||||
'user should not see another user\'s channel, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('authz', 'isolation', 'user → DELETE /channels/:id (expect 403/404)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'DELETE', '/channels/' + userChannel);
|
||||
T.assert(d._status === 403 || d._status === 404,
|
||||
'user should not delete another user\'s channel, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('authz', 'isolation', 'user → GET /channels/:id/path (expect 403/404)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/channels/' + userChannel + '/path');
|
||||
T.assert(d._status === 403 || d._status === 404,
|
||||
'user should not read another user\'s messages, got ' + d._status);
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
34
packages/icd-test-runner/js/tier-crud.js
Normal file
34
packages/icd-test-runner/js/tier-crud.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* ICD Test Runner — CRUD Tier (Orchestrator)
|
||||
*
|
||||
* Thin dispatcher that generates a shared testTag and calls each
|
||||
* CRUD group module in sequence. Each group is registered on T.crud
|
||||
* by its own IIFE (loaded via crud/*.js).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
|
||||
T.runCrud = async function () {
|
||||
var testTag = 'icd-test-' + Date.now();
|
||||
var C = T.crud || {};
|
||||
|
||||
if (C.channels) await C.channels(testTag);
|
||||
if (C.models) await C.models(testTag);
|
||||
if (C.profile) await C.profile(testTag);
|
||||
if (C.notes) await C.notes(testTag);
|
||||
if (C.projects) await C.projects(testTag);
|
||||
if (C.knowledge) await C.knowledge(testTag);
|
||||
if (C.workspaces) await C.workspaces(testTag);
|
||||
if (C.notifications) await C.notifications(testTag);
|
||||
if (C.memory) await C.memory(testTag);
|
||||
if (C.admin) await C.admin(testTag);
|
||||
if (C.workflows) await C.workflows(testTag);
|
||||
if (C.tasks) await C.tasks(testTag);
|
||||
if (C.teams) await C.teams(testTag);
|
||||
if (C.personas) await C.personas(testTag);
|
||||
if (C.extensions) await C.extensions(testTag);
|
||||
if (C.surfaces) await C.surfaces(testTag);
|
||||
};
|
||||
})();
|
||||
471
packages/icd-test-runner/js/tier-providers.js
Normal file
471
packages/icd-test-runner/js/tier-providers.js
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* ICD Test Runner — Provider Tier
|
||||
* Three-tier provider CRUD (global → team → BYOK) with live SSE completions.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
|
||||
var PROVIDER_DEFAULTS = {
|
||||
openai: 'https://api.openai.com/v1',
|
||||
anthropic: 'https://api.anthropic.com',
|
||||
venice: 'https://api.venice.ai/api/v1',
|
||||
openrouter: 'https://openrouter.ai/api/v1'
|
||||
};
|
||||
T.PROVIDER_DEFAULTS = PROVIDER_DEFAULTS;
|
||||
|
||||
// ─── SSE Completion Helper ──────────────────────────────────
|
||||
|
||||
T.testCompletion = async function (channelId, model, providerConfigId, label) {
|
||||
var token = await T.getAuthToken();
|
||||
var resp = await fetch(T.base + '/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
channel_id: channelId,
|
||||
content: 'Reply with exactly: ICD_TEST_OK',
|
||||
model: model,
|
||||
provider_config_id: providerConfigId,
|
||||
tools_enabled: false
|
||||
})
|
||||
});
|
||||
|
||||
T.assert(resp.ok, label + ': completion returned HTTP ' + resp.status);
|
||||
T.assert(resp.headers.get('content-type').indexOf('text/event-stream') !== -1,
|
||||
label + ': expected SSE, got ' + resp.headers.get('content-type'));
|
||||
|
||||
var reader = resp.body.getReader();
|
||||
var decoder = new TextDecoder();
|
||||
var content = '';
|
||||
var gotFinish = false;
|
||||
var finishData = null;
|
||||
var buf = '';
|
||||
|
||||
while (true) {
|
||||
var chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
buf += decoder.decode(chunk.value, { stream: true });
|
||||
var lines = buf.split('\n');
|
||||
buf = lines.pop();
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
if (line === 'data: [DONE]') { gotFinish = true; continue; }
|
||||
if (line.indexOf('event:') === 0) continue;
|
||||
if (line.indexOf('data:') === 0) {
|
||||
var raw = line.slice(5).trim();
|
||||
try {
|
||||
var evt = JSON.parse(raw);
|
||||
// Handler emits OpenAI-format: choices[0].delta.content
|
||||
if (evt.choices && evt.choices[0] && evt.choices[0].delta && evt.choices[0].delta.content) {
|
||||
content += evt.choices[0].delta.content;
|
||||
} else if (evt.content) {
|
||||
content += evt.content; // fallback for non-OpenAI format
|
||||
}
|
||||
if (evt.message_id) { gotFinish = true; finishData = evt; }
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
T.assert(content.length > 0, label + ': no content in stream');
|
||||
T.assert(gotFinish, label + ': no finish event');
|
||||
return { content: content, finish: finishData };
|
||||
};
|
||||
|
||||
// ─── Model Selection ────────────────────────────────────────
|
||||
|
||||
T.pickCheapestChat = function (models) {
|
||||
var skipPatterns = ['embed', 'image', 'dall-e', 'tts', 'whisper', 'moderation'];
|
||||
var chatModels = models.filter(function (m) {
|
||||
var t = m.model_type || m.type || '';
|
||||
if (t === 'embedding' || t === 'image') return false;
|
||||
// When type is missing, exclude by model ID pattern
|
||||
var mid = (m.model_id || m.id || '').toLowerCase();
|
||||
for (var i = 0; i < skipPatterns.length; i++) {
|
||||
if (mid.indexOf(skipPatterns[i]) !== -1) return false;
|
||||
}
|
||||
return (t === 'chat' || t === 'text' || t === '');
|
||||
});
|
||||
if (chatModels.length === 0) return models[0] || null;
|
||||
chatModels.sort(function (a, b) {
|
||||
return (a.output_price_per_m || a.output_price || 999) - (b.output_price_per_m || b.output_price || 999);
|
||||
});
|
||||
var cheapNames = ['mini', 'haiku', 'flash', 'fast', 'nano', 'lite', 'small'];
|
||||
for (var i = 0; i < chatModels.length; i++) {
|
||||
var mid = (chatModels[i].model_id || chatModels[i].id || '').toLowerCase();
|
||||
for (var j = 0; j < cheapNames.length; j++) {
|
||||
if (mid.indexOf(cheapNames[j]) !== -1) return chatModels[i];
|
||||
}
|
||||
}
|
||||
return chatModels[0];
|
||||
};
|
||||
|
||||
T.runProviders = async function () {
|
||||
if (!T.providerSetup.configured || !T.providerSetup.apiKey) {
|
||||
await T.test('provider', 'setup', 'SKIP — no provider configured', async function () {
|
||||
throw new Error('Configure a provider (type + API key) in the panel above, then run again');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var tag = 'icd-prov-' + Date.now();
|
||||
var prov = T.providerSetup.provider;
|
||||
var key = T.providerSetup.apiKey;
|
||||
var endpoint = T.providerSetup.endpoint || PROVIDER_DEFAULTS[prov] || '';
|
||||
|
||||
// Track IDs for scoping assertions
|
||||
var globalConfigId = null;
|
||||
var globalModel = null;
|
||||
var teamId = null;
|
||||
var teamConfigId = null;
|
||||
var teamModel = null;
|
||||
var byokConfigId = null;
|
||||
var byokModel = null;
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Tier 1: Global (admin)
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
if (T.user.role === 'admin') {
|
||||
await T.test('provider', 'global', 'POST /admin/configs (create)', async function () {
|
||||
var d = await T.apiPost('/admin/configs', {
|
||||
name: tag + '-global',
|
||||
provider: prov,
|
||||
endpoint: endpoint,
|
||||
api_key: key,
|
||||
is_private: false,
|
||||
config: {},
|
||||
headers: {},
|
||||
settings: {}
|
||||
});
|
||||
T.assertHasKey(d, 'id', 'global-config');
|
||||
globalConfigId = d.id;
|
||||
T.registerCleanup(function () { if (globalConfigId) return T.safeDelete('/admin/configs/' + globalConfigId); });
|
||||
});
|
||||
|
||||
if (globalConfigId) {
|
||||
await T.test('provider', 'global', 'GET /admin/configs/:id (read)', async function () {
|
||||
var d = await T.apiGet('/admin/configs');
|
||||
var configs = d.configs || d.data || d;
|
||||
T.assert(Array.isArray(configs), 'expected configs array');
|
||||
var found = configs.find(function (c) { return c.id === globalConfigId; });
|
||||
T.assert(found, 'created config not in list');
|
||||
T.assert(found.has_key === true, 'has_key should be true');
|
||||
// Key must be redacted
|
||||
T.assert(!found.api_key, 'api_key should be redacted');
|
||||
});
|
||||
|
||||
await T.test('provider', 'global', 'PUT /admin/configs/:id (update name)', async function () {
|
||||
var d = await T.apiPut('/admin/configs/' + globalConfigId, { name: tag + '-global-renamed' });
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
await T.test('provider', 'global', 'POST /admin/models/fetch (sync catalog)', async function () {
|
||||
var d = await T.apiPost('/admin/models/fetch', { provider_config_id: globalConfigId });
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
T.assert(d.total !== undefined || d.added !== undefined || d.message,
|
||||
'expected sync result, got keys: ' + Object.keys(d).join(', '));
|
||||
});
|
||||
|
||||
// Newly synced models may default to visibility=disabled — enable them
|
||||
await T.test('provider', 'global', 'PUT /admin/models/bulk (enable visibility)', async function () {
|
||||
var d = await T.apiPut('/admin/models/bulk', {
|
||||
provider_config_id: globalConfigId,
|
||||
visibility: 'enabled'
|
||||
});
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
await T.test('provider', 'global', 'GET /models/enabled (global visible)', async function () {
|
||||
var d = await T.apiGet('/models/enabled');
|
||||
var models = d.models || d.data || d;
|
||||
T.assert(Array.isArray(models), 'expected models array');
|
||||
var ours = models.filter(function (m) { return m.provider_config_id === globalConfigId; });
|
||||
if (ours.length === 0) {
|
||||
// Fall back to admin catalog — models may be visible there even if not in user endpoint
|
||||
var catalog = await T.apiGet('/admin/models');
|
||||
var allModels = catalog.models || catalog.data || catalog;
|
||||
if (Array.isArray(allModels)) {
|
||||
ours = allModels.filter(function (m) { return m.provider_config_id === globalConfigId; });
|
||||
}
|
||||
}
|
||||
T.assert(ours.length > 0, 'no models visible from global config ' + globalConfigId);
|
||||
globalModel = T.pickCheapestChat(ours);
|
||||
T.assert(globalModel, 'no chat model found in global config');
|
||||
});
|
||||
|
||||
if (globalModel) {
|
||||
var globalChannelId = null;
|
||||
|
||||
await T.test('provider', 'global', 'completion via global provider', async function () {
|
||||
// Create channel, run completion, verify
|
||||
var ch = await T.apiPost('/channels', {
|
||||
title: tag + '-global-test',
|
||||
type: 'direct',
|
||||
model: (globalModel.model_id || globalModel.id),
|
||||
provider_config_id: globalConfigId
|
||||
});
|
||||
globalChannelId = ch.id;
|
||||
T.registerCleanup(function () { if (globalChannelId) return T.safeDelete('/channels/' + globalChannelId); });
|
||||
|
||||
var result = await T.testCompletion(globalChannelId, (globalModel.model_id || globalModel.id), globalConfigId, 'global');
|
||||
T.assert(result.content.length > 0, 'completion produced no content');
|
||||
});
|
||||
|
||||
if (globalChannelId) {
|
||||
await T.test('provider', 'global', 'GET /channels/:id/path (message persisted)', async function () {
|
||||
var d = await T.apiGet('/channels/' + globalChannelId + '/path');
|
||||
var msgs = d.messages || d.data || d;
|
||||
T.assert(Array.isArray(msgs), 'expected messages array');
|
||||
// Should have user + assistant messages
|
||||
var roles = msgs.map(function (m) { return m.role; });
|
||||
T.assert(roles.indexOf('user') !== -1, 'missing user message');
|
||||
T.assert(roles.indexOf('assistant') !== -1, 'missing assistant message');
|
||||
});
|
||||
|
||||
await T.test('provider', 'global', 'DELETE test channel', async function () {
|
||||
await T.safeDelete('/channels/' + globalChannelId);
|
||||
globalChannelId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Tier 2: Team provider
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
if (T.user.role === 'admin') {
|
||||
await T.test('provider', 'team', 'POST /admin/teams (create test team)', async function () {
|
||||
var d = await T.apiPost('/admin/teams', { name: tag + '-team', description: 'Provider test team' });
|
||||
teamId = d.id;
|
||||
T.registerCleanup(function () { if (teamId) return T.safeDelete('/admin/teams/' + teamId); });
|
||||
// Add self as team admin
|
||||
try { await T.apiPost('/admin/teams/' + teamId + '/members', { user_id: T.user.id, role: 'admin' }); } catch (e) { /* auto-added */ }
|
||||
});
|
||||
|
||||
if (teamId) {
|
||||
await T.test('provider', 'team', 'POST /teams/:id/providers (create)', async function () {
|
||||
var d = await T.apiPost('/teams/' + teamId + '/providers', {
|
||||
name: tag + '-team-prov',
|
||||
provider: prov,
|
||||
endpoint: endpoint,
|
||||
api_key: key,
|
||||
config: {},
|
||||
headers: {},
|
||||
settings: {}
|
||||
});
|
||||
T.assertHasKey(d, 'id', 'team-config');
|
||||
teamConfigId = d.id;
|
||||
T.registerCleanup(function () { if (teamConfigId) return T.safeDelete('/teams/' + teamId + '/providers/' + teamConfigId); });
|
||||
});
|
||||
|
||||
if (teamConfigId) {
|
||||
await T.test('provider', 'team', 'GET /teams/:id/providers (list)', async function () {
|
||||
var d = await T.apiGet('/teams/' + teamId + '/providers');
|
||||
var configs = d.configs || d.data || d.providers || (Array.isArray(d) ? d : null);
|
||||
T.assert(Array.isArray(configs), 'expected configs array, got keys: ' + Object.keys(d).join(', '));
|
||||
var found = configs.find(function (c) { return c.id === teamConfigId; });
|
||||
T.assert(found, 'team config not in list');
|
||||
});
|
||||
|
||||
await T.test('provider', 'team', 'PUT /teams/:id/providers/:id (update)', async function () {
|
||||
var d = await T.apiPut('/teams/' + teamId + '/providers/' + teamConfigId, { name: tag + '-team-renamed' });
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
await T.test('provider', 'team', 'GET /teams/:id/providers/:id/models (fetch)', async function () {
|
||||
var d = await T.apiGet('/teams/' + teamId + '/providers/' + teamConfigId + '/models');
|
||||
var models = d.models || d.data || d;
|
||||
T.assert(Array.isArray(models), 'expected models array');
|
||||
if (models.length > 0) {
|
||||
teamModel = T.pickCheapestChat(models);
|
||||
}
|
||||
});
|
||||
|
||||
// If no models from team-specific fetch, try /models/enabled filtered
|
||||
if (!teamModel) {
|
||||
await T.test('provider', 'team', 'GET /models/enabled (team model fallback)', async function () {
|
||||
var d = await T.apiGet('/models/enabled');
|
||||
var models = d.models || d.data || d;
|
||||
var ours = models.filter(function (m) { return m.provider_config_id === teamConfigId; });
|
||||
if (ours.length > 0) teamModel = T.pickCheapestChat(ours);
|
||||
T.assert(teamModel, 'no chat model found for team config');
|
||||
});
|
||||
}
|
||||
|
||||
if (teamModel) {
|
||||
var teamChannelId = null;
|
||||
|
||||
await T.test('provider', 'team', 'completion via team provider', async function () {
|
||||
var ch = await T.apiPost('/channels', {
|
||||
title: tag + '-team-test',
|
||||
type: 'direct',
|
||||
model: (teamModel.model_id || teamModel.id),
|
||||
provider_config_id: teamConfigId
|
||||
});
|
||||
teamChannelId = ch.id;
|
||||
T.registerCleanup(function () { if (teamChannelId) return T.safeDelete('/channels/' + teamChannelId); });
|
||||
|
||||
var result = await T.testCompletion(teamChannelId, (teamModel.model_id || teamModel.id), teamConfigId, 'team');
|
||||
T.assert(result.content.length > 0, 'completion produced no content');
|
||||
});
|
||||
|
||||
if (teamChannelId) {
|
||||
await T.test('provider', 'team', 'DELETE test channel', async function () {
|
||||
await T.safeDelete('/channels/' + teamChannelId);
|
||||
teamChannelId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await T.test('provider', 'team', 'DELETE /teams/:id/providers/:id', async function () {
|
||||
await T.safeDelete('/teams/' + teamId + '/providers/' + teamConfigId);
|
||||
teamConfigId = null;
|
||||
});
|
||||
}
|
||||
|
||||
await T.test('provider', 'team', 'DELETE /admin/teams/:id (cleanup)', async function () {
|
||||
await T.safeDelete('/admin/teams/' + teamId);
|
||||
teamId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Tier 3: Personal BYOK
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
// BYOK requires allow_user_byok policy — enable it if admin, skip if not
|
||||
var byokPolicyWas = null; // null = don't restore
|
||||
|
||||
if (T.user.role === 'admin') {
|
||||
await T.test('provider', 'byok', 'enable allow_user_byok policy', async function () {
|
||||
// Read current policy value
|
||||
try {
|
||||
var pub = await T.apiGet('/settings/public');
|
||||
var policies = pub.policies || {};
|
||||
byokPolicyWas = policies.allow_user_byok;
|
||||
} catch (e) { /* can't read — will try to set anyway */ }
|
||||
|
||||
// Enable BYOK
|
||||
await T.apiPut('/admin/settings/allow_user_byok', { value: 'true' });
|
||||
});
|
||||
}
|
||||
|
||||
await T.test('provider', 'byok', 'POST /api-configs (create)', async function () {
|
||||
var d = await T.apiPost('/api-configs', {
|
||||
name: tag + '-byok',
|
||||
provider: prov,
|
||||
endpoint: endpoint,
|
||||
api_key: key,
|
||||
config: {},
|
||||
headers: {},
|
||||
settings: {}
|
||||
});
|
||||
T.assertHasKey(d, 'id', 'byok-config');
|
||||
byokConfigId = d.id;
|
||||
T.registerCleanup(function () { if (byokConfigId) return T.safeDelete('/api-configs/' + byokConfigId); });
|
||||
});
|
||||
|
||||
if (byokConfigId) {
|
||||
await T.test('provider', 'byok', 'GET /api-configs/:id (read — key redacted)', async function () {
|
||||
var d = await T.apiGet('/api-configs/' + byokConfigId);
|
||||
T.assertHasKey(d, 'id', 'byok-config');
|
||||
// has_key may not be present on single-config GET (only on list) — check if present
|
||||
if (d.has_key !== undefined) T.assert(d.has_key === true, 'has_key should be true');
|
||||
T.assert(!d.api_key, 'api_key should be redacted in response');
|
||||
});
|
||||
|
||||
await T.test('provider', 'byok', 'PUT /api-configs/:id (update name)', async function () {
|
||||
var d = await T.apiPut('/api-configs/' + byokConfigId, { name: tag + '-byok-renamed' });
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
await T.test('provider', 'byok', 'POST /api-configs/:id/models/fetch (sync)', async function () {
|
||||
var d = await T.apiPost('/api-configs/' + byokConfigId + '/models/fetch', {});
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
await T.test('provider', 'byok', 'GET /api-configs/:id/models (list)', async function () {
|
||||
var d = await T.apiGet('/api-configs/' + byokConfigId + '/models');
|
||||
var models = d.models || d.data || d;
|
||||
T.assert(Array.isArray(models), 'expected models array');
|
||||
T.assert(models.length > 0, 'no models returned after fetch');
|
||||
byokModel = T.pickCheapestChat(models);
|
||||
T.assert(byokModel, 'no chat model found in BYOK config');
|
||||
});
|
||||
|
||||
if (byokModel) {
|
||||
var byokChannelId = null;
|
||||
|
||||
await T.test('provider', 'byok', 'completion via BYOK provider', async function () {
|
||||
var ch = await T.apiPost('/channels', {
|
||||
title: tag + '-byok-test',
|
||||
type: 'direct',
|
||||
model: (byokModel.model_id || byokModel.id),
|
||||
provider_config_id: byokConfigId
|
||||
});
|
||||
byokChannelId = ch.id;
|
||||
T.registerCleanup(function () { if (byokChannelId) return T.safeDelete('/channels/' + byokChannelId); });
|
||||
|
||||
var result = await T.testCompletion(byokChannelId, (byokModel.model_id || byokModel.id), byokConfigId, 'byok');
|
||||
T.assert(result.content.length > 0, 'completion produced no content');
|
||||
});
|
||||
|
||||
if (byokChannelId) {
|
||||
await T.test('provider', 'byok', 'GET /channels/:id/path (message persisted)', async function () {
|
||||
var d = await T.apiGet('/channels/' + byokChannelId + '/path');
|
||||
var msgs = d.messages || d.data || d;
|
||||
T.assert(Array.isArray(msgs), 'expected messages array');
|
||||
var roles = msgs.map(function (m) { return m.role; });
|
||||
T.assert(roles.indexOf('assistant') !== -1, 'missing assistant response');
|
||||
});
|
||||
|
||||
await T.test('provider', 'byok', 'DELETE test channel', async function () {
|
||||
await T.safeDelete('/channels/' + byokChannelId);
|
||||
byokChannelId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scoping isolation: BYOK should NOT appear in admin/configs ──
|
||||
if (T.user.role === 'admin') {
|
||||
await T.test('provider', 'isolation', 'BYOK not in /admin/configs', async function () {
|
||||
var d = await T.apiGet('/admin/configs');
|
||||
var configs = d.configs || d.data || d;
|
||||
if (Array.isArray(configs)) {
|
||||
var leaked = configs.find(function (c) { return c.id === byokConfigId; });
|
||||
T.assert(!leaked, 'BYOK config leaked into global admin configs list');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await T.test('provider', 'byok', 'DELETE /api-configs/:id', async function () {
|
||||
await T.safeDelete('/api-configs/' + byokConfigId);
|
||||
byokConfigId = null;
|
||||
});
|
||||
|
||||
// After delete, should not appear in list
|
||||
await T.test('provider', 'byok', 'GET /api-configs (deleted config gone)', async function () {
|
||||
var d = await T.apiGet('/api-configs');
|
||||
var configs = d.configs || d.data || d;
|
||||
if (Array.isArray(configs)) {
|
||||
var ghost = configs.find(function (c) { return c.name && c.name.indexOf(tag) !== -1; });
|
||||
T.assert(!ghost, 'deleted BYOK config still in list');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore allow_user_byok to original value
|
||||
if (byokPolicyWas !== null && T.user.role === 'admin') {
|
||||
await T.test('provider', 'byok', 'restore allow_user_byok policy', async function () {
|
||||
await T.apiPut('/admin/settings/allow_user_byok', { value: byokPolicyWas || 'false' });
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
501
packages/icd-test-runner/js/tier-sdk.js
Normal file
501
packages/icd-test-runner/js/tier-sdk.js
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* ICD Test Runner — SDK Tier
|
||||
* Validates switchboard-sdk.js contract: boot, identity, REST client,
|
||||
* events, theme, pipe registration, execution ordering, scoping,
|
||||
* halt semantics, error isolation, extension compat shim, introspection.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/** Create a temporary direct channel for pipe tests. Returns channel object. */
|
||||
async function _createTestChannel(tag) {
|
||||
var title = 'sdk-test-' + tag + '-' + Date.now();
|
||||
var ch = await T.apiPost('/channels', { title: title, type: 'direct' });
|
||||
T.cleanup.push(function () { return T.apiDelete('/channels/' + ch.id).catch(function () {}); });
|
||||
return ch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all SDK pipe filters between tests.
|
||||
* The SDK stores filters in closure-scoped arrays; we reach them
|
||||
* through sw.pipe.list() and re-init by registering a clear flag.
|
||||
* In practice we just note that tests accumulate filters — ordering
|
||||
* tests carefully so later tests expect earlier filters to exist.
|
||||
*/
|
||||
|
||||
// ── Tier ────────────────────────────────────────────────────
|
||||
|
||||
T.runSdk = async function () {
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// BOOT
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'boot', 'Switchboard.init() returns sw object', async function () {
|
||||
T.assert(typeof Switchboard !== 'undefined', 'Switchboard global missing');
|
||||
var inst = Switchboard.init();
|
||||
T.assert(inst !== null && typeof inst === 'object', 'init() should return object');
|
||||
T.assert(inst === window.sw, 'window.sw should be the same instance');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'boot', 'Double init returns same instance', async function () {
|
||||
var a = Switchboard.init();
|
||||
var b = Switchboard.init();
|
||||
T.assert(a === b, 'idempotent: must return same object');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// IDENTITY
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'identity', 'sw.user populated', async function () {
|
||||
T.assert(sw.user !== null, 'sw.user should not be null');
|
||||
T.assert(typeof sw.user.id === 'string' && sw.user.id.length > 0, 'user.id');
|
||||
T.assert(typeof sw.user.username === 'string', 'user.username');
|
||||
T.assert(typeof sw.user.role === 'string', 'user.role');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'identity', 'sw.isAdmin reflects role', async function () {
|
||||
T.assert(typeof sw.isAdmin === 'boolean', 'isAdmin should be boolean');
|
||||
T.assert(sw.isAdmin === (sw.user.role === 'admin'), 'isAdmin should match role');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// REST CLIENT
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'api', 'sw.api.get /health', async function () {
|
||||
var d = await sw.api.get('/api/v1/health');
|
||||
T.assert(d.status === 'ok', 'health status should be ok');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'api', 'sw.api.get /channels returns envelope', async function () {
|
||||
var d = await sw.api.get('/api/v1/channels');
|
||||
T.assertHasKey(d, 'data', '/channels');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
var _sdkTestChannel = null;
|
||||
|
||||
await T.test('sdk', 'api', 'sw.api.post creates channel', async function () {
|
||||
var d = await sw.api.post('/api/v1/channels', {
|
||||
title: 'sdk-api-test-' + Date.now(), type: 'direct'
|
||||
});
|
||||
T.assert(typeof d.id === 'string', 'created channel should have id');
|
||||
_sdkTestChannel = d;
|
||||
T.cleanup.push(function () { return T.apiDelete('/channels/' + d.id).catch(function () {}); });
|
||||
});
|
||||
|
||||
await T.test('sdk', 'api', 'sw.api.del removes channel', async function () {
|
||||
if (!_sdkTestChannel) throw new Error('no channel from previous test');
|
||||
// Create a throwaway channel to delete
|
||||
var d = await sw.api.post('/api/v1/channels', {
|
||||
title: 'sdk-del-test-' + Date.now(), type: 'direct'
|
||||
});
|
||||
await sw.api.del('/api/v1/channels/' + d.id);
|
||||
// Verify it's gone
|
||||
try {
|
||||
await sw.api.get('/api/v1/channels/' + d.id);
|
||||
throw new Error('channel should be deleted');
|
||||
} catch (e) {
|
||||
if (e.message === 'channel should be deleted') throw e;
|
||||
// Expected: 404 or error
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// EVENTS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'events', 'sw.on receives sw.emit', async function () {
|
||||
var received = null;
|
||||
var unsub = sw.on('sdk.test.ping', function (payload) { received = payload; });
|
||||
sw.emit('sdk.test.ping', { v: 42 }, { localOnly: true });
|
||||
T.assert(received !== null, 'handler should have fired');
|
||||
T.assert(received.v === 42, 'payload should match');
|
||||
unsub();
|
||||
});
|
||||
|
||||
await T.test('sdk', 'events', 'sw.once fires exactly once', async function () {
|
||||
var count = 0;
|
||||
sw.once('sdk.test.once', function () { count++; });
|
||||
sw.emit('sdk.test.once', {}, { localOnly: true });
|
||||
sw.emit('sdk.test.once', {}, { localOnly: true });
|
||||
T.assert(count === 1, 'expected 1, got ' + count);
|
||||
});
|
||||
|
||||
await T.test('sdk', 'events', 'sw.off stops delivery', async function () {
|
||||
var count = 0;
|
||||
var fn = function () { count++; };
|
||||
sw.on('sdk.test.off', fn);
|
||||
sw.emit('sdk.test.off', {}, { localOnly: true });
|
||||
T.assert(count === 1, 'should fire once before off');
|
||||
sw.off('sdk.test.off', fn);
|
||||
sw.emit('sdk.test.off', {}, { localOnly: true });
|
||||
T.assert(count === 1, 'should not fire after off');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// THEME
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'theme', 'sw.theme.current returns dark or light', async function () {
|
||||
var c = sw.theme.current;
|
||||
T.assert(c === 'dark' || c === 'light', 'expected dark|light, got ' + c);
|
||||
});
|
||||
|
||||
await T.test('sdk', 'theme', 'sw.theme.mode returns preference', async function () {
|
||||
var m = sw.theme.mode;
|
||||
T.assert(m === 'dark' || m === 'light' || m === 'system', 'expected dark|light|system, got ' + m);
|
||||
});
|
||||
|
||||
await T.test('sdk', 'theme', 'sw.theme.on change fires', async function () {
|
||||
var fired = false;
|
||||
var unsub = sw.theme.on('change', function (resolved) {
|
||||
fired = true;
|
||||
T.assert(resolved === 'dark' || resolved === 'light', 'resolved should be dark|light');
|
||||
});
|
||||
// Trigger a theme change event
|
||||
Events.emit('theme.changed', {}, { localOnly: true });
|
||||
T.assert(fired, 'change handler should have fired');
|
||||
unsub();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// PIPE REGISTRATION
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'pipe', 'sw.pipe.pre registers', async function () {
|
||||
sw.pipe.pre(100, function (ctx) { return ctx; }, { source: '_test-pre' });
|
||||
var list = sw.pipe.list();
|
||||
var found = list.pre.some(function (f) { return f.source === '_test-pre'; });
|
||||
T.assert(found, '_test-pre should appear in pipe.list().pre');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'pipe', 'sw.pipe.stream registers', async function () {
|
||||
sw.pipe.stream(100, function (ctx) { return ctx; }, { source: '_test-stream' });
|
||||
var list = sw.pipe.list();
|
||||
var found = list.stream.some(function (f) { return f.source === '_test-stream'; });
|
||||
T.assert(found, '_test-stream should appear in pipe.list().stream');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'pipe', 'sw.pipe.render registers', async function () {
|
||||
sw.pipe.render(100, function (ctx) { return ctx; }, { source: '_test-render' });
|
||||
var list = sw.pipe.list();
|
||||
var found = list.render.some(function (f) { return f.source === '_test-render'; });
|
||||
T.assert(found, '_test-render should appear in pipe.list().render');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'pipe', 'Scoped filter registers with scope', async function () {
|
||||
sw.pipe.pre(101, function (ctx) { return ctx; }, {
|
||||
source: '_test-scoped',
|
||||
scope: { channelType: ['workflow'] }
|
||||
});
|
||||
var list = sw.pipe.list();
|
||||
var entry = list.pre.find(function (f) { return f.source === '_test-scoped'; });
|
||||
T.assert(entry !== undefined, 'scoped filter should be in list');
|
||||
T.assert(entry.scope !== null, 'scope should be set');
|
||||
T.assert(Array.isArray(entry.scope.channelType), 'scope.channelType should be array');
|
||||
T.assert(entry.scope.channelType[0] === 'workflow', 'should be scoped to workflow');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'pipe', 'Priority ordering', async function () {
|
||||
sw.pipe.render(5, function (ctx) { return ctx; }, { source: '_test-priority-5' });
|
||||
sw.pipe.render(99, function (ctx) { return ctx; }, { source: '_test-priority-99' });
|
||||
var list = sw.pipe.list();
|
||||
var idx5 = -1, idx99 = -1;
|
||||
for (var i = 0; i < list.render.length; i++) {
|
||||
if (list.render[i].source === '_test-priority-5') idx5 = i;
|
||||
if (list.render[i].source === '_test-priority-99') idx99 = i;
|
||||
}
|
||||
T.assert(idx5 >= 0 && idx99 >= 0, 'both filters should exist');
|
||||
T.assert(idx5 < idx99, 'priority 5 should come before priority 99');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// PIPE EXECUTION — PRE-SEND
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'pipe-pre', 'Filter mutates context', async function () {
|
||||
sw.pipe.pre(1, function (ctx) {
|
||||
ctx.metadata.sdk_test = 'injected';
|
||||
return ctx;
|
||||
}, { source: '_test-mutate' });
|
||||
|
||||
// Simulate _runPre with a fake context
|
||||
var ctx = {
|
||||
message: 'hello', channel: { id: 'fake', type: 'direct', title: '' },
|
||||
attachments: [], model: 'test', persona: null,
|
||||
metadata: {}, regenerate: false, regenerateMessageId: null
|
||||
};
|
||||
var result = sw.pipe._runPre(ctx);
|
||||
T.assert(result !== null, 'should not halt');
|
||||
T.assert(result.metadata.sdk_test === 'injected', 'metadata should be mutated');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'pipe-pre', 'Halt filter returns null', async function () {
|
||||
sw.pipe.pre(0, function (ctx) {
|
||||
if (ctx.metadata._halt_test) return null;
|
||||
return ctx;
|
||||
}, { source: '_test-halt' });
|
||||
|
||||
var ctx = {
|
||||
message: 'halt me', channel: { id: 'fake', type: 'direct', title: '' },
|
||||
attachments: [], model: 'test', persona: null,
|
||||
metadata: { _halt_test: true }, regenerate: false, regenerateMessageId: null
|
||||
};
|
||||
var result = sw.pipe._runPre(ctx);
|
||||
T.assert(result === null, 'chain should be halted');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'pipe-pre', 'Error isolation — filter throws, chain continues', async function () {
|
||||
sw.pipe.pre(2, function (ctx) {
|
||||
if (ctx.metadata._throw_test) throw new Error('intentional');
|
||||
return ctx;
|
||||
}, { source: '_test-throw' });
|
||||
|
||||
sw.pipe.pre(3, function (ctx) {
|
||||
ctx.metadata.after_throw = true;
|
||||
return ctx;
|
||||
}, { source: '_test-after-throw' });
|
||||
|
||||
var ctx = {
|
||||
message: 'test', channel: { id: 'fake', type: 'direct', title: '' },
|
||||
attachments: [], model: 'test', persona: null,
|
||||
metadata: { _throw_test: true }, regenerate: false, regenerateMessageId: null
|
||||
};
|
||||
var result = sw.pipe._runPre(ctx);
|
||||
T.assert(result !== null, 'chain should not halt on throw');
|
||||
T.assert(result.metadata.after_throw === true, 'subsequent filter should have run');
|
||||
|
||||
// Verify error was counted
|
||||
var list = sw.pipe.list();
|
||||
var entry = list.pre.find(function (f) { return f.source === '_test-throw'; });
|
||||
T.assert(entry && entry.errors > 0, 'error count should be > 0');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// PIPE EXECUTION — STREAM
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'pipe-stream', 'Stream filter counts chunks', async function () {
|
||||
var counter = 0;
|
||||
sw.pipe.stream(1, function (ctx) {
|
||||
if (ctx.channel.id === '_stream_test') counter++;
|
||||
return ctx;
|
||||
}, { source: '_test-stream-counter' });
|
||||
|
||||
// Simulate 3 chunks
|
||||
for (var i = 0; i < 3; i++) {
|
||||
sw.pipe._runStream({
|
||||
chunk: 'word' + i + ' ', accumulated: '',
|
||||
channel: { id: '_stream_test', type: 'direct' },
|
||||
model: 'test', event: 'content', tokens: { input: 0, output: 0 }
|
||||
});
|
||||
}
|
||||
T.assert(counter === 3, 'expected 3 chunk calls, got ' + counter);
|
||||
});
|
||||
|
||||
await T.test('sdk', 'pipe-stream', 'Stream filter modifies accumulated', async function () {
|
||||
sw.pipe.stream(2, function (ctx) {
|
||||
if (ctx.channel.id === '_accum_test') {
|
||||
ctx.accumulated = ctx.accumulated.toUpperCase();
|
||||
}
|
||||
return ctx;
|
||||
}, { source: '_test-stream-accum' });
|
||||
|
||||
var result = sw.pipe._runStream({
|
||||
chunk: 'hello', accumulated: 'hello world',
|
||||
channel: { id: '_accum_test', type: 'direct' },
|
||||
model: 'test', event: 'content', tokens: { input: 0, output: 0 }
|
||||
});
|
||||
T.assert(result.accumulated === 'HELLO WORLD', 'accumulated should be uppercased');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'pipe-stream', 'Null drops chunk', async function () {
|
||||
sw.pipe.stream(0, function (ctx) {
|
||||
if (ctx.channel.id === '_drop_test') return null;
|
||||
return ctx;
|
||||
}, { source: '_test-stream-drop' });
|
||||
|
||||
var result = sw.pipe._runStream({
|
||||
chunk: 'x', accumulated: 'x',
|
||||
channel: { id: '_drop_test', type: 'direct' },
|
||||
model: 'test', event: 'content', tokens: { input: 0, output: 0 }
|
||||
});
|
||||
T.assert(result === null, 'should return null for dropped chunk');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// PIPE EXECUTION — RENDER
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'pipe-render', 'Render filter modifies DOM', async function () {
|
||||
sw.pipe.render(1, function (ctx) {
|
||||
if (ctx.element.dataset.sdkRenderTest) {
|
||||
ctx.element.classList.add('sdk-render-modified');
|
||||
}
|
||||
return ctx;
|
||||
}, { source: '_test-render-dom' });
|
||||
|
||||
var el = document.createElement('div');
|
||||
el.dataset.sdkRenderTest = 'true';
|
||||
sw.pipe._runRender({
|
||||
element: el, message: null,
|
||||
channel: { id: 'fake', type: 'direct' }
|
||||
});
|
||||
T.assert(el.classList.contains('sdk-render-modified'), 'element should have class');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'pipe-render', 'Priority ordering in render chain', async function () {
|
||||
var order = [];
|
||||
sw.pipe.render(10, function (ctx) {
|
||||
if (ctx.element.dataset.sdkOrderTest) order.push('A');
|
||||
return ctx;
|
||||
}, { source: '_test-render-order-A' });
|
||||
sw.pipe.render(20, function (ctx) {
|
||||
if (ctx.element.dataset.sdkOrderTest) order.push('B');
|
||||
return ctx;
|
||||
}, { source: '_test-render-order-B' });
|
||||
|
||||
var el = document.createElement('div');
|
||||
el.dataset.sdkOrderTest = 'true';
|
||||
sw.pipe._runRender({
|
||||
element: el, message: null,
|
||||
channel: { id: 'fake', type: 'direct' }
|
||||
});
|
||||
T.assert(order.length >= 2, 'both filters should run');
|
||||
var idxA = order.indexOf('A');
|
||||
var idxB = order.indexOf('B');
|
||||
T.assert(idxA < idxB, 'A (p=10) should run before B (p=20)');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// PIPE SCOPING
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'pipe-scope', 'Scoped filter skips non-matching channel', async function () {
|
||||
var ran = false;
|
||||
sw.pipe.pre(200, function (ctx) {
|
||||
ran = true; return ctx;
|
||||
}, { source: '_test-scope-skip', scope: { channelType: ['workflow'] } });
|
||||
|
||||
sw.pipe._runPre({
|
||||
message: 'test', channel: { id: 'x', type: 'direct', title: '' },
|
||||
attachments: [], model: 'test', persona: null,
|
||||
metadata: {}, regenerate: false, regenerateMessageId: null
|
||||
});
|
||||
T.assert(!ran, 'scoped-to-workflow filter should NOT run on direct channel');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'pipe-scope', 'Scoped filter runs on matching channel', async function () {
|
||||
var ran = false;
|
||||
sw.pipe.pre(201, function (ctx) {
|
||||
ran = true; return ctx;
|
||||
}, { source: '_test-scope-match', scope: { channelType: ['dm', 'direct'] } });
|
||||
|
||||
sw.pipe._runPre({
|
||||
message: 'test', channel: { id: 'x', type: 'direct', title: '' },
|
||||
attachments: [], model: 'test', persona: null,
|
||||
metadata: {}, regenerate: false, regenerateMessageId: null
|
||||
});
|
||||
T.assert(ran, 'scoped-to-direct filter should run on direct channel');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'pipe-scope', 'Unscoped filter runs on all channels', async function () {
|
||||
var count = 0;
|
||||
sw.pipe.pre(202, function (ctx) {
|
||||
count++; return ctx;
|
||||
}, { source: '_test-scope-all' });
|
||||
|
||||
sw.pipe._runPre({
|
||||
message: 'a', channel: { id: 'x', type: 'direct', title: '' },
|
||||
attachments: [], model: 'test', persona: null,
|
||||
metadata: {}, regenerate: false, regenerateMessageId: null
|
||||
});
|
||||
sw.pipe._runPre({
|
||||
message: 'b', channel: { id: 'x', type: 'workflow', title: '' },
|
||||
attachments: [], model: 'test', persona: null,
|
||||
metadata: {}, regenerate: false, regenerateMessageId: null
|
||||
});
|
||||
T.assert(count >= 2, 'unscoped filter should run on both channel types');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// EXTENSION COMPAT SHIM
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'compat', 'ctx.renderers.register post → pipe.list() [chat-only]', async function () {
|
||||
// This tests the compat shim in extensions.js.
|
||||
// Extensions only loads on the chat surface (scripts-chat template).
|
||||
if (typeof Extensions === 'undefined') {
|
||||
T.skip('Extensions only loads on chat surface — run SDK tier from /chat to test compat shim');
|
||||
}
|
||||
var shimName = '_sdk-compat-test-' + Date.now();
|
||||
Extensions._registerRenderer('sdk-test', shimName, {
|
||||
type: 'post',
|
||||
priority: 77,
|
||||
render: function (container) { /* noop */ }
|
||||
});
|
||||
var list = sw.pipe.list();
|
||||
var found = list.render.some(function (f) {
|
||||
return f.source === 'sdk-test:' + shimName;
|
||||
});
|
||||
T.assert(found, 'shimmed post-renderer should appear in pipe.list().render');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// INTROSPECTION
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('sdk', 'introspection', 'sw.pipe.list() shape', async function () {
|
||||
var list = sw.pipe.list();
|
||||
T.assertHasKey(list, 'pre', 'pipe.list');
|
||||
T.assertHasKey(list, 'stream', 'pipe.list');
|
||||
T.assertHasKey(list, 'render', 'pipe.list');
|
||||
T.assert(Array.isArray(list.pre), 'pre should be array');
|
||||
T.assert(Array.isArray(list.stream), 'stream should be array');
|
||||
T.assert(Array.isArray(list.render), 'render should be array');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'introspection', 'Stats populated after execution', async function () {
|
||||
var list = sw.pipe.list();
|
||||
// Find any filter with calls > 0 (previous tests should have run some)
|
||||
var anyRun = false;
|
||||
['pre', 'stream', 'render'].forEach(function (stage) {
|
||||
list[stage].forEach(function (f) {
|
||||
if (f.calls > 0) anyRun = true;
|
||||
});
|
||||
});
|
||||
T.assert(anyRun, 'at least one filter should have calls > 0 from previous tests');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'introspection', 'Stats include avgMs and errors', async function () {
|
||||
var list = sw.pipe.list();
|
||||
var entry = list.pre.find(function (f) { return f.calls > 0; });
|
||||
if (!entry) throw new Error('no filter with calls > 0');
|
||||
T.assert(typeof entry.avgMs === 'number', 'avgMs should be number');
|
||||
T.assert(typeof entry.errors === 'number', 'errors should be number');
|
||||
T.assert(typeof entry.source === 'string', 'source should be string');
|
||||
T.assert(typeof entry.priority === 'number', 'priority should be number');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'introspection', 'Scoped filter shows scope in list', async function () {
|
||||
var list = sw.pipe.list();
|
||||
var scoped = list.pre.find(function (f) { return f.source === '_test-scoped'; });
|
||||
T.assert(scoped, '_test-scoped filter should exist');
|
||||
T.assert(scoped.scope !== null && scoped.scope !== undefined, 'scope should be set');
|
||||
});
|
||||
|
||||
await T.test('sdk', 'introspection', 'Unscoped filter shows null scope', async function () {
|
||||
var list = sw.pipe.list();
|
||||
var unscoped = list.pre.find(function (f) { return f.source === '_test-pre'; });
|
||||
T.assert(unscoped, '_test-pre filter should exist');
|
||||
T.assert(unscoped.scope === null, 'scope should be null for unscoped filter');
|
||||
});
|
||||
};
|
||||
})();
|
||||
719
packages/icd-test-runner/js/tier-security.js
Normal file
719
packages/icd-test-runner/js/tier-security.js
Normal file
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* ICD Test Runner — Security Tier
|
||||
* Adversarial red-team tests: auth boundary, cross-tenant isolation,
|
||||
* input validation, session security.
|
||||
*
|
||||
* REQUIRES: fixtures provisioned (3 users + team).
|
||||
*
|
||||
* Many of these tests are EXPECTED TO FIND REAL BUGS. The test names
|
||||
* include severity tags: [P0] = must-fix before 1.0,
|
||||
* [P1] = should-fix, [P2] = harden.
|
||||
*
|
||||
* 502 handling: a 502 means the request never reached the Go backend
|
||||
* (Traefik/nginx returned it). This is NOT evidence of a security
|
||||
* pass or fail — it is INCONCLUSIVE. Tests that receive 502 throw
|
||||
* with a clear "INCONCLUSIVE (502)" prefix so they show as failures
|
||||
* without being misread as security breaches.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function randomString(len) {
|
||||
var chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
var s = '';
|
||||
for (var i = 0; i < (len || 8); i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a status code indicates denial (401, 403, 404).
|
||||
* 502 = INCONCLUSIVE (proxy ate the request, backend never saw it).
|
||||
* Anything else (especially 200/201) = real security failure.
|
||||
*/
|
||||
function assertDenied(status, label) {
|
||||
if (status === 502) {
|
||||
throw new Error('INCONCLUSIVE (502): proxy returned 502 before request reached backend — ' + label);
|
||||
}
|
||||
if (status === 401 || status === 403 || status === 404) return;
|
||||
throw new Error(label + ' — got HTTP ' + status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a raw fetch() Response is a denial.
|
||||
*/
|
||||
function assertRawDenied(resp, label) {
|
||||
if (resp.status === 502) {
|
||||
throw new Error('INCONCLUSIVE (502): proxy returned 502 — ' + label +
|
||||
'. Verify Traefik/nginx forwards unauthenticated requests to the Go backend.');
|
||||
}
|
||||
if (resp.status === 401 || resp.status === 403 || resp.status === 404) return;
|
||||
throw new Error(label + ' — got HTTP ' + resp.status);
|
||||
}
|
||||
|
||||
// ─── Main Entry ───────────────────────────────────────────
|
||||
|
||||
T.runSecurity = async function () {
|
||||
if (!T.fixtures.ready) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
var testUser = T.getFixtureUser('-user');
|
||||
var teamAdmin = T.getFixtureUser('-tadmin');
|
||||
var testAdmin = T.getFixtureUser('-admin2');
|
||||
var adminToken = await T.getAuthToken();
|
||||
|
||||
if (!testUser || !testUser.token || !adminToken) {
|
||||
T.results.push({ tier: 'security', domain: 'setup', name: 'SKIP — missing tokens', status: 'fail', duration: 0,
|
||||
detail: 'Fixture users must be provisioned with valid tokens' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 1. AUTH BOUNDARY
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P0] deactivated user JWT still valid', async function () {
|
||||
var tag = 'sec-deact-' + Date.now();
|
||||
var pw = 'T3st!Pass' + randomString(6);
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create sacrificial user');
|
||||
T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
T.assert(login.access_token, 'login failed');
|
||||
var victimToken = login.access_token;
|
||||
|
||||
var pre = await T.authFetch(victimToken, 'GET', '/profile');
|
||||
T.assertStatus(pre, 200, 'pre-deactivate profile');
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: false });
|
||||
|
||||
var post = await T.authFetch(victimToken, 'GET', '/profile');
|
||||
T.assert(post._status === 401 || post._status === 403,
|
||||
'CRITICAL: deactivated user JWT still accepted! got ' + post._status +
|
||||
' — middleware does not check is_active in DB');
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P0] demoted admin JWT retains admin access', async function () {
|
||||
var tag = 'sec-demote-' + Date.now();
|
||||
var pw = 'T3st!Pass' + randomString(6);
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'admin'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create admin user');
|
||||
T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
T.assert(login.access_token, 'login failed');
|
||||
var adminJwt = login.access_token;
|
||||
|
||||
var pre = await T.authFetch(adminJwt, 'GET', '/admin/stats');
|
||||
T.assertStatus(pre, 200, 'pre-demote admin stats');
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/role', { role: 'user' });
|
||||
|
||||
var post = await T.authFetch(adminJwt, 'GET', '/admin/stats');
|
||||
T.assert(post._status === 403 || post._status === 401,
|
||||
'CRITICAL: demoted user JWT still has admin access! got ' + post._status +
|
||||
' — role is baked into JWT, not checked against DB');
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P1] refresh token reuse after logout', async function () {
|
||||
var tag = 'sec-refresh-' + Date.now();
|
||||
var pw = 'T3st!Pass' + randomString(6);
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create user');
|
||||
T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
T.assert(login.refresh_token, 'no refresh token');
|
||||
|
||||
await T.authFetch(login.access_token, 'POST', '/auth/logout', { refresh_token: login.refresh_token });
|
||||
|
||||
var reuse = await T.publicPost('/auth/refresh', { refresh_token: login.refresh_token });
|
||||
T.assert(reuse._status === 401 || reuse._status === 400,
|
||||
'revoked refresh token was accepted! got ' + reuse._status);
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P1] refresh token rotation (old invalid after use)', async function () {
|
||||
var tag = 'sec-rotate-' + Date.now();
|
||||
var pw = 'T3st!Pass' + randomString(6);
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create user');
|
||||
T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
var rt1 = login.refresh_token;
|
||||
|
||||
var refreshed = await T.publicPost('/auth/refresh', { refresh_token: rt1 });
|
||||
T.assert(refreshed._status === 200 && refreshed.access_token, 'refresh failed');
|
||||
|
||||
var replay = await T.publicPost('/auth/refresh', { refresh_token: rt1 });
|
||||
T.assert(replay._status === 401 || replay._status === 400,
|
||||
'rotated-out refresh token still valid! got ' + replay._status);
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P0] tampered JWT payload accepted', async function () {
|
||||
var parts = testUser.token.split('.');
|
||||
T.assert(parts.length === 3, 'token is not 3-part JWT');
|
||||
try {
|
||||
var payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||
payload.role = 'admin';
|
||||
var faked = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
var forgedToken = parts[0] + '.' + faked + '.' + parts[2];
|
||||
var d = await T.authFetch(forgedToken, 'GET', '/admin/stats');
|
||||
T.assert(d._status === 401,
|
||||
'CRITICAL: tampered JWT was accepted! got ' + d._status);
|
||||
} catch (e) {
|
||||
if (e.message && e.message.indexOf('CRITICAL') !== -1) throw e;
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P0] JWT alg=none accepted', async function () {
|
||||
var header = btoa(JSON.stringify({ alg: 'none', typ: 'JWT' })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
var payload = btoa(JSON.stringify({
|
||||
user_id: testUser.id, email: testUser.username + '@test.local', role: 'admin',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600
|
||||
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
var forged = header + '.' + payload + '.';
|
||||
var d = await T.authFetch(forged, 'GET', '/admin/stats');
|
||||
T.assert(d._status === 401,
|
||||
'CRITICAL: alg=none JWT was accepted! got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P1] requests with no user_id context', async function () {
|
||||
var d = await T.authFetch('eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiIn0.garbage', 'GET', '/profile');
|
||||
T.assert(d._status === 401, 'empty user_id token accepted, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('security', 'auth-boundary', '[P1] deactivated user cannot refresh', async function () {
|
||||
var tag = 'sec-deact-ref-' + Date.now();
|
||||
var pw = 'T3st!Pass' + randomString(6);
|
||||
var created = await T.apiPost('/admin/users', {
|
||||
username: tag, password: pw, email: tag + '@test.local', role: 'user'
|
||||
});
|
||||
T.assert(created && created.id, 'failed to create user');
|
||||
T.registerCleanup(function () { return T.safeDelete('/admin/users/' + created.id); });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: true });
|
||||
var login = await T.publicPost('/auth/login', { login: tag, password: pw });
|
||||
|
||||
await T.apiPut('/admin/users/' + created.id + '/active', { is_active: false });
|
||||
|
||||
var d = await T.publicPost('/auth/refresh', { refresh_token: login.refresh_token });
|
||||
T.assert(d._status === 401, 'deactivated user was able to refresh! got ' + d._status);
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 2. CROSS-TENANT DATA ACCESS
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
// ── Notes isolation ──
|
||||
|
||||
var adminNote = null;
|
||||
await T.test('security', 'cross-tenant', '[P0] user → read another user\'s note by ID', async function () {
|
||||
var d = await T.apiPost('/notes', { title: 'sec-private-' + Date.now(), content: 'SECRET DATA' });
|
||||
T.assert(d && d.id, 'note creation failed');
|
||||
adminNote = d.id;
|
||||
T.registerCleanup(function () { if (adminNote) return T.safeDelete('/notes/' + adminNote); });
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/notes/' + adminNote);
|
||||
assertDenied(steal._status, 'user can read another user\'s note');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → update another user\'s note', async function () {
|
||||
if (!adminNote) { T.assert(false, 'depends on previous test'); return; }
|
||||
var d = await T.authFetch(testUser.token, 'PUT', '/notes/' + adminNote, { title: 'HACKED' });
|
||||
assertDenied(d._status, 'user can update another user\'s note');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → delete another user\'s note', async function () {
|
||||
if (!adminNote) { T.assert(false, 'depends on previous test'); return; }
|
||||
var d = await T.authFetch(testUser.token, 'DELETE', '/notes/' + adminNote);
|
||||
assertDenied(d._status, 'user can delete another user\'s note');
|
||||
});
|
||||
|
||||
// ── Memory isolation (no POST /memories — system-extracted only) ──
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → GET /admin/memories/pending (expect deny)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/admin/memories/pending');
|
||||
assertDenied(d._status, 'user can list admin pending memories');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → PUT /memories/:fakeId (expect deny)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'PUT', '/memories/00000000-0000-0000-0000-000000000000', { value: 'HACKED' });
|
||||
assertDenied(d._status, 'user can hit PUT /memories with fabricated ID');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → DELETE /memories/:fakeId (expect deny)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'DELETE', '/memories/00000000-0000-0000-0000-000000000000');
|
||||
assertDenied(d._status, 'user can hit DELETE /memories with fabricated ID');
|
||||
});
|
||||
|
||||
// ── Channel / message isolation ──
|
||||
|
||||
var adminChannel = null;
|
||||
await T.test('security', 'cross-tenant', '[P0] user → read another user\'s channel messages', async function () {
|
||||
var ch = await T.apiPost('/channels', { title: 'sec-private-ch-' + Date.now(), type: 'direct' });
|
||||
T.assert(ch && ch.id, 'channel creation failed');
|
||||
adminChannel = ch.id;
|
||||
T.registerCleanup(function () { if (adminChannel) return T.safeDelete('/channels/' + adminChannel); });
|
||||
|
||||
await T.apiPost('/channels/' + adminChannel + '/messages', {
|
||||
role: 'user', content: 'TOP SECRET MESSAGE'
|
||||
});
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/channels/' + adminChannel + '/messages');
|
||||
assertDenied(steal._status, 'user can read another user\'s messages');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → POST message to another user\'s channel', async function () {
|
||||
if (!adminChannel) { T.assert(false, 'depends on previous test'); return; }
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/channels/' + adminChannel + '/messages', {
|
||||
role: 'user', content: 'INJECTED'
|
||||
});
|
||||
assertDenied(d._status, 'user can inject messages into another user\'s channel');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → update another user\'s channel', async function () {
|
||||
if (!adminChannel) { T.assert(false, 'depends on previous test'); return; }
|
||||
var d = await T.authFetch(testUser.token, 'PUT', '/channels/' + adminChannel, { title: 'HACKED' });
|
||||
assertDenied(d._status, 'user can rename another user\'s channel');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → archive another user\'s channel', async function () {
|
||||
if (!adminChannel) { T.assert(false, 'depends on previous test'); return; }
|
||||
var d = await T.authFetch(testUser.token, 'PUT', '/channels/' + adminChannel, { is_archived: true });
|
||||
assertDenied(d._status, 'user can archive another user\'s channel');
|
||||
});
|
||||
|
||||
// ── BYOK config isolation ──
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → enumerate BYOK configs', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/api-configs');
|
||||
T.assertStatus(d, 200, 'api-configs');
|
||||
var arr = d.data || d.configs || [];
|
||||
if (Array.isArray(arr)) {
|
||||
arr.forEach(function (cfg) {
|
||||
if (cfg.user_id) {
|
||||
T.assert(cfg.user_id === testUser.id,
|
||||
'BYOK config user_id mismatch — leaking other user\'s config');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Task isolation ──
|
||||
|
||||
var adminTask = null;
|
||||
await T.test('security', 'cross-tenant', '[P0] user → read another user\'s task', async function () {
|
||||
try {
|
||||
var d = await T.apiPost('/tasks', {
|
||||
name: 'sec-task-' + Date.now(), task_type: 'scheduled',
|
||||
schedule: '0 0 * * *', scope: 'personal',
|
||||
prompt: 'test', model: 'test'
|
||||
});
|
||||
adminTask = (d && d.id) ? d.id : (d && d.task && d.task.id) ? d.task.id : null;
|
||||
if (adminTask) T.registerCleanup(function () { return T.safeDelete('/tasks/' + adminTask); });
|
||||
} catch (e) { /* may require permissions */ }
|
||||
|
||||
if (!adminTask) {
|
||||
var adm = await T.authFetch(testUser.token, 'GET', '/admin/tasks');
|
||||
assertDenied(adm._status, 'user can list admin tasks');
|
||||
return;
|
||||
}
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/tasks/' + adminTask);
|
||||
assertDenied(steal._status, 'user can read another user\'s task');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → delete another user\'s task', async function () {
|
||||
if (!adminTask) {
|
||||
var d = await T.authFetch(testUser.token, 'DELETE', '/tasks/00000000-0000-0000-0000-000000000000');
|
||||
assertDenied(d._status, 'user can hit DELETE /tasks with fabricated ID');
|
||||
return;
|
||||
}
|
||||
var d = await T.authFetch(testUser.token, 'DELETE', '/tasks/' + adminTask);
|
||||
assertDenied(d._status, 'user can delete another user\'s task');
|
||||
});
|
||||
|
||||
// ── Workspace isolation ──
|
||||
|
||||
var adminWs = null;
|
||||
await T.test('security', 'cross-tenant', '[P0] user → read another user\'s workspace', async function () {
|
||||
try {
|
||||
var d = await T.apiPost('/workspaces', {
|
||||
name: 'sec-ws-' + Date.now(), owner_type: 'user', owner_id: T.user.id
|
||||
});
|
||||
if (d && d.id) {
|
||||
adminWs = d.id;
|
||||
T.registerCleanup(function () { return T.safeDelete('/workspaces/' + adminWs); });
|
||||
}
|
||||
} catch (e) { /* may fail */ }
|
||||
if (!adminWs) return;
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/workspaces/' + adminWs);
|
||||
assertDenied(steal._status, 'user can read another user\'s workspace');
|
||||
});
|
||||
|
||||
// ── Cross-team access ──
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → access non-member team providers', async function () {
|
||||
var otherTeam = null;
|
||||
try {
|
||||
var d = await T.apiPost('/admin/teams', { name: 'sec-other-team-' + Date.now(), description: 'isolation test' });
|
||||
otherTeam = d.id;
|
||||
T.registerCleanup(function () { if (otherTeam) return T.safeDelete('/admin/teams/' + otherTeam); });
|
||||
} catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; }
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/providers');
|
||||
assertDenied(steal._status, 'user can list providers for a team they are not in');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → access non-member team personas', async function () {
|
||||
var otherTeam = null;
|
||||
try {
|
||||
var d = await T.apiPost('/admin/teams', { name: 'sec-other-team2-' + Date.now(), description: 'isolation test' });
|
||||
otherTeam = d.id;
|
||||
T.registerCleanup(function () { if (otherTeam) return T.safeDelete('/admin/teams/' + otherTeam); });
|
||||
} catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; }
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/personas');
|
||||
assertDenied(steal._status, 'user can list personas for a team they are not in');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → access non-member team members list', async function () {
|
||||
var otherTeam = null;
|
||||
try {
|
||||
var d = await T.apiPost('/admin/teams', { name: 'sec-other-team3-' + Date.now(), description: 'isolation test' });
|
||||
otherTeam = d.id;
|
||||
T.registerCleanup(function () { if (otherTeam) return T.safeDelete('/admin/teams/' + otherTeam); });
|
||||
} catch (e) { T.assert(false, 'team creation failed: ' + e.message); return; }
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/teams/' + otherTeam + '/members');
|
||||
assertDenied(steal._status, 'user can list members of a team they are not in');
|
||||
});
|
||||
|
||||
// ── Project isolation ──
|
||||
|
||||
var adminProject = null;
|
||||
await T.test('security', 'cross-tenant', '[P0] user → read another user\'s personal project', async function () {
|
||||
try {
|
||||
var d = await T.apiPost('/projects', { name: 'sec-project-' + Date.now(), scope: 'personal' });
|
||||
if (d && d.id) {
|
||||
adminProject = d.id;
|
||||
T.registerCleanup(function () { return T.safeDelete('/projects/' + adminProject); });
|
||||
}
|
||||
} catch (e) { /* may fail */ }
|
||||
if (!adminProject) return;
|
||||
|
||||
var steal = await T.authFetch(testUser.token, 'GET', '/projects/' + adminProject);
|
||||
assertDenied(steal._status, 'user can read another user\'s project');
|
||||
});
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P0] user → delete another user\'s project', async function () {
|
||||
if (!adminProject) return;
|
||||
var d = await T.authFetch(testUser.token, 'DELETE', '/projects/' + adminProject);
|
||||
assertDenied(d._status, 'user can delete another user\'s project');
|
||||
});
|
||||
|
||||
// ── Notification isolation ──
|
||||
|
||||
await T.test('security', 'cross-tenant', '[P1] user → list only sees own notifications', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/notifications');
|
||||
T.assertStatus(d, 200, 'notifications');
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 3. INPUT VALIDATION
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
var sqliPayloads = [
|
||||
"' OR '1'='1",
|
||||
"'; DROP TABLE users; --",
|
||||
"' UNION SELECT id, password_hash, email, role, 'x', 'x' FROM users --",
|
||||
"1; SELECT pg_sleep(5)--",
|
||||
"' OR 1=1--"
|
||||
];
|
||||
|
||||
for (var si = 0; si < sqliPayloads.length; si++) {
|
||||
await (async function (payload, idx) {
|
||||
await T.test('security', 'input-validation', '[P0] SQLi in notes search (' + (idx + 1) + '/' + sqliPayloads.length + ')', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET',
|
||||
'/notes?query=' + encodeURIComponent(payload));
|
||||
T.assert(d._status !== 500,
|
||||
'SQL injection caused server error (500)! Payload: ' + payload);
|
||||
});
|
||||
})(sqliPayloads[si], si);
|
||||
}
|
||||
|
||||
await T.test('security', 'input-validation', '[P0] SQLi in channel search', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET',
|
||||
'/channels?query=' + encodeURIComponent("' UNION SELECT * FROM users--"));
|
||||
T.assert(d._status !== 500, 'SQL injection in channel search caused 500');
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P0] SQLi in admin user search', async function () {
|
||||
var d = await T.authFetch(adminToken, 'GET',
|
||||
'/admin/users?query=' + encodeURIComponent("' OR '1'='1"));
|
||||
T.assert(d._status !== 500, 'SQL injection in admin user search caused 500');
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] XSS in channel title (stored)', async function () {
|
||||
var ch = await T.apiPost('/channels', { title: '<script>alert("xss")</script>', type: 'direct' });
|
||||
if (ch && ch.id) {
|
||||
T.registerCleanup(function () { return T.safeDelete('/channels/' + ch.id); });
|
||||
var readBack = await T.apiGet('/channels/' + ch.id);
|
||||
T.assert(readBack._status !== 500, 'XSS channel title caused 500');
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] XSS in note content (stored)', async function () {
|
||||
var n = await T.apiPost('/notes', { title: 'xss-test', content: '<img src=x onerror=alert(document.cookie)>' });
|
||||
if (n && n.id) {
|
||||
T.registerCleanup(function () { return T.safeDelete('/notes/' + n.id); });
|
||||
var readBack = await T.apiGet('/notes/' + n.id);
|
||||
T.assert(readBack._status !== 500, 'XSS note content caused 500');
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] XSS in persona name (stored)', async function () {
|
||||
try {
|
||||
var p = await T.apiPost('/personas', { name: '"><svg/onload=alert(1)>', scope: 'personal', model: 'test' });
|
||||
if (p && p.id) T.registerCleanup(function () { return T.safeDelete('/personas/' + p.id); });
|
||||
} catch (e) { /* server rejection is fine */ }
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] oversized channel title (10KB)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/channels', { title: 'A'.repeat(10240), type: 'direct' });
|
||||
if ((d._status === 200 || d._status === 201) && d.id) {
|
||||
T.registerCleanup(function () { return T.safeDelete('/channels/' + d.id); });
|
||||
}
|
||||
T.assert(d._status !== 500, 'oversized title caused 500');
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] oversized note content (5MB)', async function () {
|
||||
try {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/notes', { title: 'big', content: 'X'.repeat(5 * 1024 * 1024) });
|
||||
if (d && d.id) T.registerCleanup(function () { return T.safeDelete('/notes/' + d.id); });
|
||||
} catch (e) { /* rejection is fine */ }
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] malformed JSON body', async function () {
|
||||
var resp = await fetch(T.base + '/api/v1/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + adminToken, 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: '{title: invalid, no-quotes}'
|
||||
});
|
||||
if (resp.status === 502) {
|
||||
throw new Error('INCONCLUSIVE (502): proxy returned 502 — may not forward malformed bodies to backend');
|
||||
}
|
||||
T.assert(resp.status === 400, 'malformed JSON should return 400, got ' + resp.status);
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] Content-Type mismatch (form data as JSON)', async function () {
|
||||
var resp = await fetch(T.base + '/api/v1/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + adminToken, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
credentials: 'same-origin',
|
||||
body: 'title=test&type=direct'
|
||||
});
|
||||
if (resp.status === 502) {
|
||||
throw new Error('INCONCLUSIVE (502): proxy returned 502 on content-type mismatch');
|
||||
}
|
||||
T.assert(resp.status === 400 || resp.status === 415,
|
||||
'form data accepted on JSON endpoint, got ' + resp.status);
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P0] path traversal in surface archive', async function () {
|
||||
var blob = new Blob([JSON.stringify({ id: '../../../etc/evil', title: 'Path Traversal Test' })], { type: 'application/json' });
|
||||
try {
|
||||
var d = await T.apiUpload('/admin/surfaces/install', blob, 'evil.surface');
|
||||
T.assert(d._status === 400 || d._status === 409,
|
||||
'path traversal surface accepted! got ' + d._status);
|
||||
} catch (e) {
|
||||
if (e.message.indexOf('502') !== -1) {
|
||||
throw new Error('INCONCLUSIVE (502): proxy returned 502 on surface upload');
|
||||
}
|
||||
T.assert(e.message.indexOf('400') !== -1 || e.message.indexOf('409') !== -1 || e.message.indexOf('415') !== -1,
|
||||
'unexpected error: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P1] null byte in channel ID param', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/channels/abc%00def');
|
||||
T.assert(d._status !== 500, 'null byte in ID caused 500');
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P1] extremely long ID param', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'GET', '/channels/' + 'a'.repeat(4096));
|
||||
T.assert(d._status !== 500, 'extremely long ID caused 500');
|
||||
});
|
||||
|
||||
await T.test('security', 'input-validation', '[P2] unicode RTLO in channel title', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/channels', { title: 'normal\u202Eelif_gnol', type: 'direct' });
|
||||
if ((d._status === 200 || d._status === 201) && d.id) {
|
||||
T.registerCleanup(function () { return T.safeDelete('/channels/' + d.id); });
|
||||
}
|
||||
T.assert(d._status !== 500, 'RTLO character caused 500');
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 4. SESSION SECURITY (unauthenticated access)
|
||||
// Raw fetch() without auth headers. 502 = INCONCLUSIVE
|
||||
// (proxy may not forward unauthenticated requests).
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('security', 'session', '[P0] unauthenticated → GET /channels (expect 401)', async function () {
|
||||
var resp = await fetch(T.base + '/api/v1/channels', { method: 'GET', credentials: 'same-origin' });
|
||||
assertRawDenied(resp, 'unauthenticated access to /channels');
|
||||
});
|
||||
|
||||
await T.test('security', 'session', '[P0] unauthenticated → GET /profile (expect 401)', async function () {
|
||||
var resp = await fetch(T.base + '/api/v1/profile', { method: 'GET', credentials: 'same-origin' });
|
||||
assertRawDenied(resp, 'unauthenticated access to /profile');
|
||||
});
|
||||
|
||||
await T.test('security', 'session', '[P0] unauthenticated → GET /admin/users (expect 401)', async function () {
|
||||
var resp = await fetch(T.base + '/api/v1/admin/users', { method: 'GET', credentials: 'same-origin' });
|
||||
assertRawDenied(resp, 'unauthenticated access to admin');
|
||||
});
|
||||
|
||||
await T.test('security', 'session', '[P0] unauthenticated → POST /channels (expect 401)', async function () {
|
||||
var resp = await fetch(T.base + '/api/v1/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ title: 'unauth-test', type: 'direct' })
|
||||
});
|
||||
assertRawDenied(resp, 'unauthenticated channel creation');
|
||||
});
|
||||
|
||||
await T.test('security', 'session', '[P1] cookie-only → attempt JWT-protected endpoints', async function () {
|
||||
var d = await T.sessionGet('/notes');
|
||||
if (d._status === 502) throw new Error('INCONCLUSIVE (502): proxy returned 502 for cookie-only request');
|
||||
T.assert(d._status === 401, 'cookie-only access to /notes returned ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('security', 'session', '[P1] cookie-only → attempt admin endpoints', async function () {
|
||||
var d = await T.sessionGet('/admin/stats');
|
||||
if (d._status === 502) throw new Error('INCONCLUSIVE (502): proxy returned 502 for cookie-only request');
|
||||
T.assert(d._status === 401, 'cookie-only access to /admin/stats returned ' + d._status);
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 5. PRIVILEGE ESCALATION ATTEMPTS
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('security', 'escalation', '[P0] user → PUT own role to admin', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'PUT', '/admin/users/' + testUser.id + '/role', { role: 'admin' });
|
||||
assertDenied(d._status, 'CRITICAL: user was able to self-promote to admin');
|
||||
});
|
||||
|
||||
await T.test('security', 'escalation', '[P0] user → POST /admin/users with admin role', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/users', {
|
||||
username: 'should-never-exist-' + Date.now(), password: 'Hack3d!xx',
|
||||
email: 'hack@evil.com', role: 'admin'
|
||||
});
|
||||
assertDenied(d._status, 'CRITICAL: non-admin created an admin user');
|
||||
});
|
||||
|
||||
if (teamAdmin && teamAdmin.token) {
|
||||
await T.test('security', 'escalation', '[P0] teamAdmin → PUT /admin/settings', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'PUT', '/admin/settings/allow_registration', { value: true });
|
||||
assertDenied(d._status, 'team admin can modify platform settings');
|
||||
});
|
||||
|
||||
await T.test('security', 'escalation', '[P0] teamAdmin → DELETE /admin/users/:id', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'DELETE', '/admin/users/' + testUser.id);
|
||||
assertDenied(d._status, 'CRITICAL: team admin can delete users');
|
||||
});
|
||||
|
||||
await T.test('security', 'escalation', '[P0] teamAdmin → POST /admin/surfaces/install', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'POST', '/admin/surfaces/install', {});
|
||||
if (d._status === 400) return; // bad upload format, not a security issue
|
||||
assertDenied(d._status, 'team admin can install surfaces');
|
||||
});
|
||||
}
|
||||
|
||||
await T.test('security', 'escalation', '[P0] user_id substitution in channel creation', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/channels', {
|
||||
title: 'impersonation-test-' + Date.now(), type: 'direct', user_id: T.user.id
|
||||
});
|
||||
if (d._status === 200 || d._status === 201) {
|
||||
if (d.id) T.registerCleanup(function () { return T.safeDelete('/channels/' + d.id); });
|
||||
T.assert(!d.user_id || d.user_id === testUser.id,
|
||||
'CRITICAL: channel created as another user! owner=' + d.user_id);
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'escalation', '[P0] user_id substitution in note creation', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/notes', {
|
||||
title: 'impersonation-' + Date.now(), content: 'test', user_id: T.user.id
|
||||
});
|
||||
if (d._status === 200 || d._status === 201) {
|
||||
if (d.id) T.registerCleanup(function () { return T.safeDelete('/notes/' + d.id); });
|
||||
if (d.user_id) {
|
||||
T.assert(d.user_id === testUser.id,
|
||||
'CRITICAL: note created as another user! owner=' + d.user_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 6. HEADER / TRANSPORT SECURITY
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
await T.test('security', 'transport', '[P2] CORS allows wildcard origin', async function () {
|
||||
var resp = await fetch(T.base + '/api/v1/health', {
|
||||
method: 'OPTIONS',
|
||||
headers: { 'Origin': 'https://evil.example.com', 'Access-Control-Request-Method': 'GET' }
|
||||
});
|
||||
var acao = resp.headers.get('Access-Control-Allow-Origin');
|
||||
if (acao === '*') {
|
||||
T.assert(false, 'CORS returns Access-Control-Allow-Origin: * — restrict in production');
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'transport', '[P2] auth endpoint rate limiting', async function () {
|
||||
var statuses = [];
|
||||
for (var i = 0; i < 10; i++) {
|
||||
try {
|
||||
var d = await T.publicPost('/auth/login', { login: 'nonexistent', password: 'wrong' });
|
||||
statuses.push(d._status);
|
||||
} catch (e) { statuses.push('err'); }
|
||||
}
|
||||
var backendStatuses = statuses.filter(function (s) { return s !== 502; });
|
||||
if (backendStatuses.length === 0) {
|
||||
throw new Error('INCONCLUSIVE (502): all login attempts returned 502 — cannot test rate limiting');
|
||||
}
|
||||
var rateLimited = backendStatuses.some(function (s) { return s === 429; });
|
||||
if (!rateLimited) {
|
||||
T.assert(false, 'no rate limiting on /auth/login after ' + backendStatuses.length +
|
||||
' attempts reaching backend — statuses: [' + backendStatuses.join(',') + ']');
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'transport', '[P2] WebSocket token in query string visible in logs', async function () {
|
||||
T.assert(false,
|
||||
'INFO: WebSocket uses ?token= query param (JWT visible in server logs, proxy logs, browser history). ' +
|
||||
'Consider using a short-lived ticket exchange instead.');
|
||||
});
|
||||
};
|
||||
|
||||
})();
|
||||
448
packages/icd-test-runner/js/tier-smoke.js
Normal file
448
packages/icd-test-runner/js/tier-smoke.js
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* ICD Test Runner — Smoke Tier
|
||||
* Read-only GET tests for every endpoint domain.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
|
||||
T.runSmoke = async function () {
|
||||
// -- Health --
|
||||
await T.test('smoke', 'utility', 'GET /health', async function () {
|
||||
var d = await T.apiGet('/health');
|
||||
T.assert(d.status === 'ok', 'expected status "ok", got "' + d.status + '"');
|
||||
});
|
||||
|
||||
// -- Public Settings --
|
||||
await T.test('smoke', 'admin', 'GET /settings/public', async function () {
|
||||
var d = await T.apiGet('/settings/public');
|
||||
T.assertHasKey(d, 'policies', 'public_settings');
|
||||
T.assert(typeof d.policies === 'object', 'policies should be object');
|
||||
});
|
||||
|
||||
// -- Profile --
|
||||
await T.test('smoke', 'profile', 'GET /profile', async function () {
|
||||
var d = await T.apiGet('/profile');
|
||||
T.assertShape(d, T.S.profile, 'profile');
|
||||
T.assert(d.username.length > 0, 'username should be non-empty');
|
||||
});
|
||||
|
||||
// -- User Settings --
|
||||
await T.test('smoke', 'profile', 'GET /settings (user)', async function () {
|
||||
var d = await T.apiGet('/settings');
|
||||
T.assertHasKey(d, 'settings', 'user_settings');
|
||||
});
|
||||
|
||||
// -- Models --
|
||||
await T.test('smoke', 'models', 'GET /models/enabled', async function () {
|
||||
var d = await T.apiGet('/models/enabled');
|
||||
T.assertHasKey(d, 'data', '/models/enabled');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
if (d.data.length > 0) {
|
||||
T.assertShape(d.data[0], T.S.modelEnabled, 'data[0]');
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('smoke', 'models', 'GET /models/preferences', 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');
|
||||
if (d.data.length > 0) {
|
||||
T.assertShape(d.data[0], T.S.modelPreference, 'data[0]');
|
||||
}
|
||||
});
|
||||
|
||||
// -- Surfaces --
|
||||
await T.test('smoke', 'surfaces', 'GET /surfaces', async function () {
|
||||
var d = await T.apiGet('/surfaces');
|
||||
T.assertHasKey(d, 'data', '/surfaces');
|
||||
T.assertArrayOf(d.data, T.S.surfaceNav, 'surfaces');
|
||||
// Our own surface should be in the list
|
||||
var found = d.data.some(function (s) { return s.id === 'icd-test-runner'; });
|
||||
T.assert(found, 'icd-test-runner not found in surfaces list');
|
||||
});
|
||||
|
||||
// -- Channels --
|
||||
await T.test('smoke', 'channels', 'GET /channels', async function () {
|
||||
var d = await T.apiGet('/channels');
|
||||
// Paginated envelope or { data: [...] }
|
||||
var arr = d.data || d.channels;
|
||||
T.assert(Array.isArray(arr), 'expected data array from /channels');
|
||||
});
|
||||
|
||||
// -- Personas --
|
||||
await T.test('smoke', 'personas', 'GET /personas', async function () {
|
||||
var d = await T.apiGet('/personas');
|
||||
T.assertHasKey(d, 'data', '/personas');
|
||||
T.assert(Array.isArray(d.data), 'personas should be array');
|
||||
});
|
||||
|
||||
// -- Persona Groups --
|
||||
await T.test('smoke', 'personas', 'GET /persona-groups', async function () {
|
||||
var d = await T.apiGet('/persona-groups');
|
||||
T.assertHasKey(d, 'data', '/persona-groups');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// -- Knowledge Bases --
|
||||
await T.test('smoke', 'knowledge', 'GET /knowledge-bases', async function () {
|
||||
var d = await T.apiGet('/knowledge-bases');
|
||||
T.assertHasKey(d, 'data', '/knowledge-bases');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'knowledge', 'GET /knowledge-bases-discoverable', async function () {
|
||||
var d = await T.apiGet('/knowledge-bases-discoverable');
|
||||
T.assertHasKey(d, 'data', '/kb-discoverable');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// -- Notes --
|
||||
await T.test('smoke', 'notes', 'GET /notes', async function () {
|
||||
var d = await T.apiGet('/notes');
|
||||
// Paginated or { data: [...] }
|
||||
var arr = d.data || d.notes;
|
||||
T.assert(Array.isArray(arr), 'expected array from /notes');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'notes', 'GET /notes/folders', async function () {
|
||||
var d = await T.apiGet('/notes/folders');
|
||||
T.assertHasKey(d, 'folders', '/notes/folders');
|
||||
T.assert(Array.isArray(d.folders), 'folders should be array');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'notes', 'GET /notes/graph', async function () {
|
||||
var d = await T.apiGet('/notes/graph');
|
||||
T.assertHasKey(d, 'nodes', '/notes/graph');
|
||||
T.assertHasKey(d, 'edges', '/notes/graph');
|
||||
T.assert(Array.isArray(d.nodes), 'nodes should be array');
|
||||
T.assert(Array.isArray(d.edges), 'edges should be array');
|
||||
});
|
||||
|
||||
// -- Projects --
|
||||
await T.test('smoke', 'projects', 'GET /projects', async function () {
|
||||
var d = await T.apiGet('/projects');
|
||||
T.assertHasKey(d, 'data', '/projects');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// -- Memories --
|
||||
await T.test('smoke', 'memory', 'GET /memories', async function () {
|
||||
var d = await T.apiGet('/memories');
|
||||
T.assertHasKey(d, 'data', '/memories');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'memory', 'GET /memories/count', 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');
|
||||
});
|
||||
|
||||
// -- Notifications --
|
||||
await T.test('smoke', 'notifications', 'GET /notifications', async function () {
|
||||
var d = await T.apiGet('/notifications');
|
||||
T.assertHasKey(d, 'data', '/notifications');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
T.assertHasKey(d, 'total', '/notifications');
|
||||
T.assert(typeof d.total === 'number', 'total should be number');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'notifications', 'GET /notifications/unread-count', async function () {
|
||||
var d = await T.apiGet('/notifications/unread-count');
|
||||
T.assertHasKey(d, 'count', '/notifications/unread-count');
|
||||
T.assert(typeof d.count === 'number', 'count should be number');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'notifications', 'GET /notifications/preferences', async function () {
|
||||
var d = await T.apiGet('/notifications/preferences');
|
||||
T.assertHasKey(d, 'data', '/notifications/preferences');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// -- Extensions --
|
||||
await T.test('smoke', 'extensions', 'GET /extensions', async function () {
|
||||
var d = await T.apiGet('/extensions');
|
||||
T.assertHasKey(d, 'data', '/extensions');
|
||||
T.assert(Array.isArray(d.data), 'extensions should be array');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'extensions', 'GET /extensions/tools', 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');
|
||||
});
|
||||
|
||||
// -- Tools --
|
||||
await T.test('smoke', 'channels', 'GET /tools', async function () {
|
||||
var d = await T.apiGet('/tools');
|
||||
T.assertHasKey(d, 'tools', '/tools');
|
||||
T.assert(Array.isArray(d.tools), 'tools should be array');
|
||||
});
|
||||
|
||||
// -- Provider Configs (BYOK) --
|
||||
await T.test('smoke', 'providers', 'GET /api-configs', async function () {
|
||||
var d = await T.apiGet('/api-configs');
|
||||
T.assertHasKey(d, 'configs', '/api-configs');
|
||||
T.assert(Array.isArray(d.configs), 'configs should be array');
|
||||
});
|
||||
|
||||
// -- Teams --
|
||||
await T.test('smoke', 'teams', 'GET /teams/mine', async function () {
|
||||
var d = await T.apiGet('/teams/mine');
|
||||
T.assertHasKey(d, 'data', '/teams/mine');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// -- Groups --
|
||||
await T.test('smoke', 'teams', 'GET /groups/mine', async function () {
|
||||
var d = await T.apiGet('/groups/mine');
|
||||
T.assertHasKey(d, 'data', '/groups/mine');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// -- Tasks --
|
||||
await T.test('smoke', 'tasks', 'GET /tasks', async function () {
|
||||
var d = await T.apiGet('/tasks');
|
||||
T.assertHasKey(d, 'data', '/tasks');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// -- Workspaces --
|
||||
await T.test('smoke', 'workspaces', 'GET /workspaces', async function () {
|
||||
var d = await T.apiGet('/workspaces');
|
||||
T.assertHasKey(d, 'data', '/workspaces');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// -- Folders --
|
||||
await T.test('smoke', 'channels', 'GET /folders', async function () {
|
||||
var d = await T.apiGet('/folders');
|
||||
// May be { data: [...] } or { folders: [...] }
|
||||
var arr = d.data || d.folders;
|
||||
T.assert(arr === undefined || Array.isArray(arr), 'expected array from /folders');
|
||||
});
|
||||
|
||||
// -- Files --
|
||||
await T.test('smoke', 'channels', 'GET /files', async function () {
|
||||
var d = await T.apiGet('/files');
|
||||
T.assertHasKey(d, 'files', '/files');
|
||||
T.assert(Array.isArray(d.files), 'files should be array');
|
||||
T.assert(typeof d.total === 'number', 'expected total count');
|
||||
});
|
||||
|
||||
// -- Usage --
|
||||
await T.test('smoke', 'admin', 'GET /usage', async function () {
|
||||
var d = await T.apiGet('/usage');
|
||||
// personal usage — may have totals, results
|
||||
T.assert(typeof d === 'object', 'expected object from /usage');
|
||||
});
|
||||
|
||||
// -- Workflow Assignments --
|
||||
await T.test('smoke', 'workflows', 'GET /workflow-assignments/mine', async function () {
|
||||
var d = await T.apiGet('/workflow-assignments/mine');
|
||||
// May be { data: [...] } or { assignments: [...] }
|
||||
var arr = d.data || d.assignments;
|
||||
T.assert(arr === undefined || Array.isArray(arr), 'expected array from /workflow-assignments/mine');
|
||||
});
|
||||
|
||||
// -- Workflows --
|
||||
await T.test('smoke', 'workflows', 'GET /workflows', async function () {
|
||||
var d = await T.apiGet('/workflows');
|
||||
T.assertHasKey(d, 'data', '/workflows');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// -- Git Credentials --
|
||||
await T.test('smoke', 'workspaces', 'GET /git-credentials', async function () {
|
||||
var d = await T.apiGet('/git-credentials');
|
||||
T.assertHasKey(d, 'data', '/git-credentials');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// -- Presence --
|
||||
await T.test('smoke', 'channels', 'POST /presence/heartbeat', async function () {
|
||||
var d = await T.apiPost('/presence/heartbeat', {});
|
||||
T.assert(d.ok === true, 'expected { ok: true }');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'channels', 'GET /presence (query)', async function () {
|
||||
var d = await T.apiGet('/presence?users=' + encodeURIComponent(T.user.id));
|
||||
T.assertHasKey(d, 'presence', '/presence');
|
||||
T.assert(typeof d.presence === 'object', 'presence should be object');
|
||||
// After heartbeat, our own user should be online
|
||||
if (d.presence[T.user.id]) {
|
||||
T.assert(d.presence[T.user.id] === 'online', 'expected self online after heartbeat');
|
||||
}
|
||||
});
|
||||
|
||||
// -- Admin routes (only if admin) --
|
||||
if (T.user.role === 'admin') {
|
||||
await T.test('smoke', 'admin', 'GET /admin/stats', async function () {
|
||||
var d = await T.apiGet('/admin/stats');
|
||||
T.assertHasKey(d, 'users', '/admin/stats');
|
||||
T.assertHasKey(d, 'channels', '/admin/stats');
|
||||
T.assertHasKey(d, 'messages', '/admin/stats');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/users', async function () {
|
||||
var d = await T.apiGet('/admin/users');
|
||||
T.assertHasKey(d, 'users', '/admin/users');
|
||||
T.assert(Array.isArray(d.users), 'users should be array');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/settings', async function () {
|
||||
var d = await T.apiGet('/admin/settings');
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/configs', async function () {
|
||||
var d = await T.apiGet('/admin/configs');
|
||||
T.assertHasKey(d, 'configs', '/admin/configs');
|
||||
T.assert(Array.isArray(d.configs), 'configs should be array');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/models', async function () {
|
||||
var d = await T.apiGet('/admin/models');
|
||||
T.assertHasKey(d, 'models', '/admin/models');
|
||||
T.assert(Array.isArray(d.models), 'models should be array');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/personas', async function () {
|
||||
var d = await T.apiGet('/admin/personas');
|
||||
T.assertHasKey(d, 'data', '/admin/personas');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/teams', async function () {
|
||||
var d = await T.apiGet('/admin/teams');
|
||||
T.assertHasKey(d, 'data', '/admin/teams');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/groups', async function () {
|
||||
var d = await T.apiGet('/admin/groups');
|
||||
T.assertHasKey(d, 'data', '/admin/groups');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/surfaces', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||
T.assert(Array.isArray(d.data), 'surfaces should be array');
|
||||
if (d.data.length > 0) {
|
||||
T.assertShape(d.data[0], T.S.surfaceAdmin, 'surfaces[0]');
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/providers/health', async function () {
|
||||
var d = await T.apiGet('/admin/providers/health');
|
||||
T.assertHasKey(d, 'data', '/admin/providers/health');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/capability-overrides', async function () {
|
||||
var d = await T.apiGet('/admin/capability-overrides');
|
||||
T.assertHasKey(d, 'data', '/admin/capability-overrides');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/routing/policies', async function () {
|
||||
var d = await T.apiGet('/admin/routing/policies');
|
||||
T.assertHasKey(d, 'data', '/admin/routing/policies');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/permissions', async function () {
|
||||
var d = await T.apiGet('/admin/permissions');
|
||||
T.assertHasKey(d, 'permissions', '/admin/permissions');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/roles', async function () {
|
||||
var d = await T.apiGet('/admin/roles');
|
||||
// Returns a raw JSONMap of role configs (e.g. { chat: {...}, summary: {...} })
|
||||
T.assert(typeof d === 'object' && d !== null, 'expected object');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/vault/status', async function () {
|
||||
var d = await T.apiGet('/admin/vault/status');
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/storage/status', async function () {
|
||||
var d = await T.apiGet('/admin/storage/status');
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/audit', async function () {
|
||||
var d = await T.apiGet('/admin/audit');
|
||||
T.assertHasKey(d, 'data', '/admin/audit');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
T.assertHasKey(d, 'total', '/admin/audit');
|
||||
T.assertHasKey(d, 'page', '/admin/audit');
|
||||
T.assertHasKey(d, 'per_page', '/admin/audit');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/audit/actions', async function () {
|
||||
var d = await T.apiGet('/admin/audit/actions');
|
||||
T.assertHasKey(d, 'actions', '/admin/audit/actions');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/usage', async function () {
|
||||
var d = await T.apiGet('/admin/usage');
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/pricing', async function () {
|
||||
var d = await T.apiGet('/admin/pricing');
|
||||
// Returns a bare array (no envelope)
|
||||
T.assert(Array.isArray(d), '/admin/pricing should return array');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/provider-types', async function () {
|
||||
var d = await T.apiGet('/admin/provider-types');
|
||||
T.assertHasKey(d, 'data', '/admin/provider-types');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/tasks', 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');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/projects', async function () {
|
||||
var d = await T.apiGet('/admin/projects');
|
||||
T.assertHasKey(d, 'data', '/admin/projects');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/channels/archived', async function () {
|
||||
var d = await T.apiGet('/admin/channels/archived');
|
||||
T.assertHasKey(d, 'channels', '/admin/channels/archived');
|
||||
T.assert(Array.isArray(d.channels), 'channels should be array');
|
||||
T.assertHasKey(d, 'total', '/admin/channels/archived');
|
||||
T.assertHasKey(d, 'retention_mode', '/admin/channels/archived');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/storage/orphans', async function () {
|
||||
var d = await T.apiGet('/admin/storage/orphans');
|
||||
T.assertHasKey(d, 'count', '/admin/storage/orphans');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/settings/:key (banner)', async function () {
|
||||
var d = await T.apiGet('/admin/settings/banner');
|
||||
T.assertHasKey(d, 'key', '/admin/settings/:key');
|
||||
T.assertHasKey(d, 'value', '/admin/settings/:key');
|
||||
T.assert(d.key === 'banner', 'key should be "banner"');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/storage/extraction', async function () {
|
||||
var d = await T.apiGet('/admin/storage/extraction');
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'memory', 'GET /admin/memories/pending', 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');
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
739
packages/icd-test-runner/js/ui.js
Normal file
739
packages/icd-test-runner/js/ui.js
Normal file
@@ -0,0 +1,739 @@
|
||||
/**
|
||||
* ICD Test Runner — UI
|
||||
* Render functions, suite dispatcher, provider setup panel, fixtures panel, export.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
var $ = T.$;
|
||||
var esc = T.esc;
|
||||
|
||||
// ─── Provider Setup UI Panel ──────────────────────────────
|
||||
|
||||
T.renderProviderSetup = function () {
|
||||
if (!T.el.providerSetup) return;
|
||||
T.el.providerSetup.innerHTML = '';
|
||||
|
||||
var cardStyle = {
|
||||
background: 'var(--bg-surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-lg)', padding: '16px', marginBottom: '0'
|
||||
};
|
||||
|
||||
var card = $('div', { style: cardStyle });
|
||||
|
||||
// Header row
|
||||
var hdr = $('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' } }, [
|
||||
$('div', { style: { fontSize: '13px', fontWeight: '600', color: 'var(--text)' } }, 'Provider Test Setup'),
|
||||
T.providerSetup.configured
|
||||
? $('span', { className: 'badge badge-success', style: { fontSize: '11px' } },
|
||||
esc(T.providerSetup.provider) + ' configured')
|
||||
: $('span', { style: { fontSize: '11px', color: 'var(--text-3)' } }, 'Not configured — optional')
|
||||
]);
|
||||
card.appendChild(hdr);
|
||||
|
||||
// Input row
|
||||
var inputRow = $('div', { style: { display: 'flex', gap: '10px', alignItems: 'flex-end', flexWrap: 'wrap' } });
|
||||
|
||||
// Provider select
|
||||
var selWrap = $('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } }, [
|
||||
$('label', { style: { fontSize: '11px', color: 'var(--text-3)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.5px' } }, 'Provider')
|
||||
]);
|
||||
var sel = $('select', {
|
||||
style: {
|
||||
background: 'var(--bg-raised)', color: 'var(--text)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)', padding: '7px 10px', fontSize: '13px', fontFamily: 'var(--font)',
|
||||
minWidth: '140px'
|
||||
}
|
||||
});
|
||||
['openai', 'anthropic', 'venice', 'openrouter'].forEach(function (p) {
|
||||
var opt = $('option', { value: p }, p);
|
||||
if (p === T.providerSetup.provider) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.addEventListener('change', function () { T.providerSetup.provider = sel.value; });
|
||||
selWrap.appendChild(sel);
|
||||
inputRow.appendChild(selWrap);
|
||||
|
||||
// API Key input
|
||||
var keyWrap = $('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', flex: '1', minWidth: '240px' } }, [
|
||||
$('label', { style: { fontSize: '11px', color: 'var(--text-3)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.5px' } }, 'API Key')
|
||||
]);
|
||||
var keyInput = $('input', {
|
||||
type: 'password',
|
||||
placeholder: 'sk-... / anthropic-... / paste key',
|
||||
style: {
|
||||
background: 'var(--bg-raised)', color: 'var(--text)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)', padding: '7px 10px', fontSize: '13px', fontFamily: 'var(--mono)',
|
||||
width: '100%'
|
||||
}
|
||||
});
|
||||
keyInput.value = T.providerSetup.apiKey;
|
||||
keyInput.addEventListener('input', function () { T.providerSetup.apiKey = keyInput.value; });
|
||||
keyWrap.appendChild(keyInput);
|
||||
inputRow.appendChild(keyWrap);
|
||||
|
||||
// Endpoint override (collapsed by default)
|
||||
var epWrap = $('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '200px' } }, [
|
||||
$('label', { style: { fontSize: '11px', color: 'var(--text-3)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.5px' } }, 'Endpoint (optional)')
|
||||
]);
|
||||
var epInput = $('input', {
|
||||
type: 'text',
|
||||
placeholder: T.PROVIDER_DEFAULTS[T.providerSetup.provider] || 'default',
|
||||
style: {
|
||||
background: 'var(--bg-raised)', color: 'var(--text)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)', padding: '7px 10px', fontSize: '12px', fontFamily: 'var(--mono)',
|
||||
width: '100%'
|
||||
}
|
||||
});
|
||||
epInput.value = T.providerSetup.endpoint;
|
||||
epInput.addEventListener('input', function () { T.providerSetup.endpoint = epInput.value; });
|
||||
epWrap.appendChild(epInput);
|
||||
inputRow.appendChild(epWrap);
|
||||
|
||||
// Configure button
|
||||
var cfgBtn = $('button', {
|
||||
className: T.providerSetup.configured ? 'btn-ghost' : 'btn-primary',
|
||||
style: { alignSelf: 'flex-end', whiteSpace: 'nowrap' },
|
||||
onClick: function () {
|
||||
if (!T.providerSetup.apiKey) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Paste an API key first', 'warning');
|
||||
return;
|
||||
}
|
||||
T.providerSetup.configured = true;
|
||||
T.renderProviderSetup();
|
||||
T.renderControls();
|
||||
if (typeof UI !== 'undefined') UI.toast(T.providerSetup.provider + ' configured — run Provider tier', 'success');
|
||||
}
|
||||
}, T.providerSetup.configured ? 'Reconfigure' : 'Configure');
|
||||
inputRow.appendChild(cfgBtn);
|
||||
|
||||
if (T.providerSetup.configured) {
|
||||
var clearBtn = $('button', {
|
||||
className: 'btn-ghost',
|
||||
style: { alignSelf: 'flex-end', color: 'var(--danger)', whiteSpace: 'nowrap' },
|
||||
onClick: function () {
|
||||
T.providerSetup.apiKey = '';
|
||||
T.providerSetup.configured = false;
|
||||
T.renderProviderSetup();
|
||||
T.renderControls();
|
||||
}
|
||||
}, 'Clear Key');
|
||||
inputRow.appendChild(clearBtn);
|
||||
}
|
||||
|
||||
card.appendChild(inputRow);
|
||||
|
||||
// Security note
|
||||
card.appendChild($('div', {
|
||||
style: { fontSize: '11px', color: 'var(--text-3)', marginTop: '8px', fontStyle: 'italic' }
|
||||
}, 'Key stays in browser memory only — never persisted, cleaned up on page close. Creates temporary configs and deletes on teardown.'));
|
||||
|
||||
T.el.providerSetup.appendChild(card);
|
||||
}
|
||||
|
||||
|
||||
|
||||
T.renderShell = function () {
|
||||
T.mount.innerHTML = '';
|
||||
T.mount.style.padding = '24px 32px';
|
||||
T.mount.style.maxWidth = '1000px';
|
||||
T.mount.style.margin = '0 auto';
|
||||
|
||||
// Header
|
||||
var hdr = $('div', { style: { marginBottom: '24px' } }, [
|
||||
$('h1', { style: { fontSize: '22px', fontWeight: '700', color: 'var(--text)', margin: '0 0 4px 0' } },
|
||||
'ICD Test Runner'),
|
||||
$('div', { style: { fontSize: '13px', color: 'var(--text-3)' } },
|
||||
'v' + (T.manifest.version || '0.28.0') + ' · ' + esc(T.user.username) + (T.user.role === 'admin' ? ' (admin)' : ' (user)'))
|
||||
]);
|
||||
T.mount.appendChild(hdr);
|
||||
|
||||
// Controls
|
||||
T.el.controls = $('div', { style: { display: 'flex', gap: '10px', marginBottom: '16px', flexWrap: 'wrap', alignItems: 'center' } });
|
||||
T.mount.appendChild(T.el.controls);
|
||||
|
||||
// Fixtures panel (admin only)
|
||||
if (T.user.role === 'admin') {
|
||||
T.el.fixtures = $('div', { style: { marginBottom: '16px' } });
|
||||
T.mount.appendChild(T.el.fixtures);
|
||||
}
|
||||
|
||||
// Provider setup panel
|
||||
T.el.providerSetup = $('div', { style: { marginBottom: '16px' } });
|
||||
T.mount.appendChild(T.el.providerSetup);
|
||||
|
||||
// Progress
|
||||
T.el.progress = $('div', { style: { marginBottom: '16px' } });
|
||||
T.mount.appendChild(T.el.progress);
|
||||
|
||||
// Summary
|
||||
T.el.summary = $('div', { style: { marginBottom: '20px' } });
|
||||
T.mount.appendChild(T.el.summary);
|
||||
|
||||
// Detail table
|
||||
T.el.detail = $('div', {});
|
||||
T.mount.appendChild(T.el.detail);
|
||||
|
||||
T.renderControls();
|
||||
T.renderFixtures();
|
||||
T.renderProviderSetup();
|
||||
}
|
||||
|
||||
T.renderControls = function () {
|
||||
T.el.controls.innerHTML = '';
|
||||
var btnSmoke = $('button', {
|
||||
className: 'btn-primary',
|
||||
onClick: function () { T.runSuite('smoke'); }
|
||||
}, 'Smoke');
|
||||
var btnCrud = $('button', {
|
||||
className: 'btn-secondary',
|
||||
onClick: function () { T.runSuite('crud'); }
|
||||
}, 'CRUD');
|
||||
var btnAll = $('button', {
|
||||
className: 'btn-primary',
|
||||
onClick: function () { T.runSuite('all'); }
|
||||
}, 'Run All');
|
||||
var btnClear = $('button', {
|
||||
className: 'btn-ghost',
|
||||
onClick: function () { T.results = []; T.renderProgress(); T.renderSummary(); T.renderDetail(); }
|
||||
}, 'Clear');
|
||||
T.el.controls.appendChild(btnSmoke);
|
||||
T.el.controls.appendChild(btnCrud);
|
||||
|
||||
// AuthZ button — only useful if admin (needs fixtures)
|
||||
if (T.user.role === 'admin') {
|
||||
var btnAuthz = $('button', {
|
||||
className: T.fixtures.ready ? 'btn-secondary' : 'btn-ghost',
|
||||
style: { opacity: T.fixtures.ready ? '1' : '0.5' },
|
||||
onClick: function () { T.runSuite('authz'); }
|
||||
}, 'AuthZ');
|
||||
T.el.controls.appendChild(btnAuthz);
|
||||
|
||||
var btnSecurity = $('button', {
|
||||
className: T.fixtures.ready ? 'btn-secondary' : 'btn-ghost',
|
||||
style: {
|
||||
opacity: T.fixtures.ready ? '1' : '0.5',
|
||||
borderColor: T.fixtures.ready ? 'var(--danger)' : undefined,
|
||||
color: T.fixtures.ready ? 'var(--danger)' : undefined
|
||||
},
|
||||
onClick: function () { T.runSuite('security'); }
|
||||
}, 'Security');
|
||||
T.el.controls.appendChild(btnSecurity);
|
||||
}
|
||||
|
||||
// Provider tier button — needs provider setup
|
||||
var btnProv = $('button', {
|
||||
className: T.providerSetup.configured ? 'btn-secondary' : 'btn-ghost',
|
||||
style: { opacity: T.providerSetup.configured ? '1' : '0.5' },
|
||||
onClick: function () { T.runSuite('provider'); }
|
||||
}, 'Providers');
|
||||
T.el.controls.appendChild(btnProv);
|
||||
|
||||
// SDK tier button — tests switchboard-sdk.js contract
|
||||
var btnSdk = $('button', {
|
||||
className: (typeof Switchboard !== 'undefined') ? 'btn-secondary' : 'btn-ghost',
|
||||
style: { opacity: (typeof Switchboard !== 'undefined') ? '1' : '0.5' },
|
||||
onClick: function () { T.runSuite('sdk'); }
|
||||
}, 'SDK');
|
||||
T.el.controls.appendChild(btnSdk);
|
||||
|
||||
T.el.controls.appendChild(btnAll);
|
||||
T.el.controls.appendChild(btnClear);
|
||||
|
||||
// Separator
|
||||
T.el.controls.appendChild($('div', { style: { width: '1px', height: '28px', background: 'var(--border)', flexShrink: '0' } }));
|
||||
|
||||
// Export buttons
|
||||
var btnCopy = $('button', {
|
||||
className: 'btn-ghost',
|
||||
onClick: function () { T.exportReport('clipboard'); }
|
||||
}, 'Copy Report');
|
||||
var btnDownload = $('button', {
|
||||
className: 'btn-ghost',
|
||||
onClick: function () { T.exportReport('download'); }
|
||||
}, 'Download');
|
||||
T.el.controls.appendChild(btnCopy);
|
||||
T.el.controls.appendChild(btnDownload);
|
||||
|
||||
// Fixture controls (admin)
|
||||
if (T.user.role === 'admin') {
|
||||
T.el.controls.appendChild($('div', { style: { width: '1px', height: '28px', background: 'var(--border)', flexShrink: '0' } }));
|
||||
if (!T.fixtures.ready) {
|
||||
T.el.controls.appendChild($('button', {
|
||||
className: 'btn-secondary',
|
||||
onClick: async function () { await T.provisionFixtures(); T.renderControls(); }
|
||||
}, 'Provision Test Users'));
|
||||
} else {
|
||||
T.el.controls.appendChild($('button', {
|
||||
className: 'btn-ghost',
|
||||
style: { color: 'var(--danger)' },
|
||||
onClick: async function () { await T.teardownFixtures(); T.renderControls(); }
|
||||
}, 'Tear Down Fixtures'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fixtures Panel ─────────────────────────────────────────
|
||||
|
||||
T.renderFixtures = function () {
|
||||
if (!T.el.fixtures) return;
|
||||
T.el.fixtures.innerHTML = '';
|
||||
if (!T.fixtures.ready) return;
|
||||
|
||||
var panel = $('div', {
|
||||
style: {
|
||||
background: 'var(--bg-surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-lg)', padding: '14px 18px',
|
||||
borderLeft: '3px solid var(--accent)'
|
||||
}
|
||||
});
|
||||
|
||||
panel.appendChild($('div', {
|
||||
style: { fontSize: '13px', fontWeight: '600', color: 'var(--text)', marginBottom: '10px' }
|
||||
}, 'Test Fixtures'));
|
||||
|
||||
// Users table
|
||||
var tbl = $('table', {
|
||||
style: { width: '100%', borderCollapse: 'collapse', fontSize: '12px', fontFamily: 'var(--mono)' }
|
||||
});
|
||||
|
||||
var hdr = $('tr', { style: { borderBottom: '1px solid var(--border)' } });
|
||||
['Role', 'Username', 'Password', 'Token', 'ID'].forEach(function (h) {
|
||||
hdr.appendChild($('th', { style: { textAlign: 'left', padding: '4px 8px', color: 'var(--text-3)', fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.5px' } }, h));
|
||||
});
|
||||
tbl.appendChild(hdr);
|
||||
|
||||
T.fixtures.users.forEach(function (u) {
|
||||
var tr = $('tr', { style: { borderBottom: '1px solid var(--border)' } });
|
||||
tr.appendChild($('td', { style: { padding: '4px 8px', color: 'var(--accent)' } }, u.role));
|
||||
tr.appendChild($('td', { style: { padding: '4px 8px', color: 'var(--text)' } }, u.username));
|
||||
|
||||
// Password with copy button
|
||||
var pwTd = $('td', { style: { padding: '4px 8px' } });
|
||||
var pwSpan = $('span', { style: { color: 'var(--warning)', cursor: 'pointer' }, title: 'Click to copy' }, u.password);
|
||||
pwSpan.addEventListener('click', function () {
|
||||
navigator.clipboard.writeText(u.password);
|
||||
if (typeof UI !== 'undefined') UI.toast('Password copied', 'info');
|
||||
});
|
||||
pwTd.appendChild(pwSpan);
|
||||
tr.appendChild(pwTd);
|
||||
|
||||
// Token status
|
||||
var tokenStatus = u.token ? 'yes' : 'FAILED';
|
||||
var tokenColor = u.token ? 'var(--success)' : 'var(--danger)';
|
||||
tr.appendChild($('td', { style: { padding: '4px 8px', color: tokenColor } }, tokenStatus));
|
||||
|
||||
// ID (truncated)
|
||||
tr.appendChild($('td', { style: { padding: '4px 8px', color: 'var(--text-3)' } }, (u.id || '').substring(0, 8) + '…'));
|
||||
|
||||
tbl.appendChild(tr);
|
||||
});
|
||||
panel.appendChild(tbl);
|
||||
|
||||
// Team/group info
|
||||
var meta = [];
|
||||
if (T.fixtures.team) meta.push('Team: ' + T.fixtures.team.name + ' (' + T.fixtures.team.id.substring(0, 8) + '…)');
|
||||
if (T.fixtures.group) meta.push('Group: ' + T.fixtures.group.name + ' (' + T.fixtures.group.id.substring(0, 8) + '…)');
|
||||
if (meta.length > 0) {
|
||||
panel.appendChild($('div', {
|
||||
style: { marginTop: '8px', fontSize: '11px', color: 'var(--text-3)' }
|
||||
}, meta.join(' · ')));
|
||||
}
|
||||
|
||||
T.el.fixtures.appendChild(panel);
|
||||
}
|
||||
|
||||
// ─── Export ─────────────────────────────────────────────────
|
||||
|
||||
function buildReport() {
|
||||
var now = new Date().toISOString();
|
||||
var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length;
|
||||
var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length;
|
||||
var skip = T.results.filter(function (r) { return r.status === 'skip'; }).length;
|
||||
var total = T.results.length;
|
||||
var totalMs = T.results.reduce(function (s, r) { return s + r.duration; }, 0);
|
||||
|
||||
var lines = [];
|
||||
lines.push('=== Chat Switchboard ICD Test Report ===');
|
||||
lines.push('Generated: ' + now);
|
||||
lines.push('Platform: v' + (T.manifest.version || '0.28.0'));
|
||||
lines.push('User: ' + T.user.username + ' (' + T.user.role + ')');
|
||||
lines.push('URL: ' + location.href);
|
||||
lines.push('');
|
||||
lines.push('--- Summary ---');
|
||||
lines.push('Total: ' + total + ' Pass: ' + pass + ' Fail: ' + fail + (skip > 0 ? ' Skip: ' + skip : '') + ' Rate: ' + (total > 0 ? Math.round((pass / (total - skip)) * 100) : 0) + '% Duration: ' + totalMs + 'ms');
|
||||
|
||||
// Critical failures callout
|
||||
var criticals = T.results.filter(function (r) { return r.status === 'fail' && r.detail && r.detail.indexOf('CRITICAL') !== -1; });
|
||||
if (criticals.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('!!! ' + criticals.length + ' CRITICAL SECURITY FAILURE(S) !!!');
|
||||
criticals.forEach(function (r) {
|
||||
lines.push(' → [' + r.tier + '/' + r.domain + '] ' + r.name);
|
||||
lines.push(' ' + r.detail);
|
||||
});
|
||||
}
|
||||
|
||||
// Fixture info
|
||||
if (T.fixtures.ready) {
|
||||
lines.push('');
|
||||
lines.push('--- Test Fixtures ---');
|
||||
T.fixtures.users.forEach(function (u) {
|
||||
lines.push(' ' + pad(u.role, 8) + pad(u.username, 40) + (u.token ? 'token:OK' : 'token:FAIL'));
|
||||
});
|
||||
if (T.fixtures.team) lines.push(' Team: ' + T.fixtures.team.name + ' (' + T.fixtures.team.id + ')');
|
||||
if (T.fixtures.group) lines.push(' Group: ' + T.fixtures.group.name + ' (' + T.fixtures.group.id + ')');
|
||||
}
|
||||
|
||||
// Provider config info
|
||||
if (T.providerSetup.configured) {
|
||||
lines.push('');
|
||||
lines.push('--- Provider Setup ---');
|
||||
lines.push(' Type: ' + T.providerSetup.provider);
|
||||
lines.push(' Endpoint: ' + (T.providerSetup.endpoint || T.PROVIDER_DEFAULTS[T.providerSetup.provider] || 'default'));
|
||||
lines.push(' Key: ' + (T.providerSetup.apiKey ? T.providerSetup.apiKey.slice(0, 8) + '...' : 'none'));
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Domain breakdown
|
||||
var domains = {};
|
||||
T.results.forEach(function (r) {
|
||||
var k = r.domain;
|
||||
if (!domains[k]) domains[k] = { pass: 0, fail: 0, skip: 0, total: 0, ms: 0 };
|
||||
domains[k].total++;
|
||||
domains[k].ms += r.duration;
|
||||
if (r.status === 'pass') domains[k].pass++;
|
||||
else if (r.status === 'skip') domains[k].skip++;
|
||||
else domains[k].fail++;
|
||||
});
|
||||
|
||||
lines.push('--- By Domain ---');
|
||||
Object.keys(domains).forEach(function (d) {
|
||||
var s = domains[d];
|
||||
var flag = s.fail > 0 ? 'FAIL' : (s.skip > 0 && s.pass === 0 ? 'SKIP' : 'OK');
|
||||
lines.push(' ' + pad(d, 16) + pad(flag, 6) + pad(s.pass + '/' + s.total, 8) + s.ms + 'ms');
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
// Failures only (if any)
|
||||
var failures = T.results.filter(function (r) { return r.status === 'fail'; });
|
||||
if (failures.length > 0) {
|
||||
lines.push('--- Failures (' + failures.length + ') ---');
|
||||
failures.forEach(function (r, i) {
|
||||
lines.push(' ' + (i + 1) + '. [' + r.tier + '/' + r.domain + '] ' + r.name);
|
||||
lines.push(' ' + r.detail);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Skipped (if any)
|
||||
var skipped = T.results.filter(function (r) { return r.status === 'skip'; });
|
||||
if (skipped.length > 0) {
|
||||
lines.push('--- Skipped (' + skipped.length + ') ---');
|
||||
skipped.forEach(function (r, i) {
|
||||
lines.push(' ' + (i + 1) + '. [' + r.tier + '/' + r.domain + '] ' + r.name);
|
||||
lines.push(' ' + r.detail);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Full detail table
|
||||
lines.push('--- Full Results ---');
|
||||
lines.push(pad('STATUS', 8) + pad('TIER', 8) + pad('DOMAIN', 16) + pad('MS', 6) + 'TEST');
|
||||
lines.push(repeat('-', 90));
|
||||
T.results.forEach(function (r) {
|
||||
var statusStr = r.status === 'pass' ? 'PASS' : (r.status === 'skip' ? 'SKIP' : 'FAIL');
|
||||
var line = pad(statusStr, 8) + pad(r.tier, 8) + pad(r.domain, 16) + pad(String(r.duration), 6) + r.name;
|
||||
if (r.status === 'fail' && r.detail) {
|
||||
line += '\n' + repeat(' ', 38) + '↳ ' + r.detail;
|
||||
}
|
||||
if (r.status === 'skip' && r.detail) {
|
||||
line += '\n' + repeat(' ', 38) + '↳ SKIP: ' + r.detail;
|
||||
}
|
||||
lines.push(line);
|
||||
});
|
||||
lines.push('');
|
||||
lines.push('=== END ===');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function pad(s, w) {
|
||||
s = String(s);
|
||||
while (s.length < w) s += ' ';
|
||||
return s;
|
||||
}
|
||||
|
||||
function repeat(ch, n) {
|
||||
var s = '';
|
||||
for (var i = 0; i < n; i++) s += ch;
|
||||
return s;
|
||||
}
|
||||
|
||||
T.exportReport = function (mode) {
|
||||
if (T.results.length === 0) {
|
||||
if (typeof UI !== 'undefined') UI.toast('No T.results to export — run a suite first', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
var text = buildReport();
|
||||
|
||||
if (mode === 'clipboard') {
|
||||
navigator.clipboard.writeText(text).then(function () {
|
||||
if (typeof UI !== 'undefined') UI.toast('Report copied to clipboard', 'success');
|
||||
}).catch(function () {
|
||||
// Fallback: select-all textarea
|
||||
clipboardFallback(text);
|
||||
});
|
||||
} else if (mode === 'download') {
|
||||
var ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
var filename = 'icd-report-' + ts + '.txt';
|
||||
var blob = new Blob([text], { type: 'text/plain' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
if (typeof UI !== 'undefined') UI.toast('Downloaded ' + filename, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function clipboardFallback(text) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (e) { /* give up */ }
|
||||
document.body.removeChild(ta);
|
||||
if (typeof UI !== 'undefined') UI.toast('Report copied (fallback)', 'info');
|
||||
}
|
||||
|
||||
|
||||
|
||||
T.runSuite = async function (which) {
|
||||
if (T.running) return;
|
||||
if (which === 'authz' && !T.fixtures.ready) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first (button on the right)', 'warning');
|
||||
return;
|
||||
}
|
||||
if (which === 'security' && !T.fixtures.ready) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first (button on the right)', 'warning');
|
||||
return;
|
||||
}
|
||||
if (which === 'provider' && !T.providerSetup.configured) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Configure a provider (type + API key) in the panel above', 'warning');
|
||||
return;
|
||||
}
|
||||
T.running = true;
|
||||
T.results = [];
|
||||
|
||||
// Capture auth token before any test (needed for DELETE fallback)
|
||||
await T.captureAuthToken();
|
||||
|
||||
T.renderProgress();
|
||||
T.renderSummary();
|
||||
T.renderDetail();
|
||||
|
||||
try {
|
||||
if (which === 'smoke' || which === 'all') await T.runSmoke();
|
||||
if (which === 'crud' || which === 'all') await T.runCrud();
|
||||
if (which === 'authz' || (which === 'all' && T.fixtures.ready)) await T.runAuthz();
|
||||
if (which === 'security' || (which === 'all' && T.fixtures.ready)) await T.runSecurity();
|
||||
if (which === 'provider' || (which === 'all' && T.providerSetup.configured)) await T.runProviders();
|
||||
if (which === 'sdk' || which === 'all') { if (typeof T.runSdk === 'function') await T.runSdk(); }
|
||||
} catch (e) {
|
||||
T.results.push({ tier: '?', domain: 'runner', name: 'FATAL', status: 'fail', duration: 0, detail: String(e) });
|
||||
}
|
||||
|
||||
// Always T.cleanup (CRUD-created resources, not fixtures)
|
||||
await T.runCleanup();
|
||||
|
||||
T.running = false;
|
||||
T.renderSummary();
|
||||
T.renderDetail();
|
||||
T.renderProgress();
|
||||
if (typeof UI !== 'undefined') {
|
||||
var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length;
|
||||
var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length;
|
||||
var critical = T.results.filter(function (r) { return r.status === 'fail' && r.detail && r.detail.indexOf('CRITICAL') !== -1; }).length;
|
||||
var msg = pass + ' passed, ' + fail + ' failed';
|
||||
if (critical > 0) msg += ' (' + critical + ' CRITICAL)';
|
||||
UI.toast(msg, critical > 0 ? 'error' : fail > 0 ? 'warning' : 'success');
|
||||
}
|
||||
}
|
||||
|
||||
T.renderProgress = function () {
|
||||
if (!T.el.progress) return;
|
||||
var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length;
|
||||
var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length;
|
||||
var skip = T.results.filter(function (r) { return r.status === 'skip'; }).length;
|
||||
var total = T.results.length;
|
||||
var countable = total - skip;
|
||||
var pct = countable > 0 ? Math.round((pass / countable) * 100) : 0;
|
||||
|
||||
T.el.progress.innerHTML = '';
|
||||
if (total === 0) return;
|
||||
|
||||
// Progress bar
|
||||
var barOuter = $('div', {
|
||||
style: {
|
||||
height: '8px', borderRadius: '4px', background: 'var(--bg-raised)',
|
||||
overflow: 'hidden', marginBottom: '8px'
|
||||
}
|
||||
});
|
||||
var barInner = $('div', {
|
||||
style: {
|
||||
height: '100%', borderRadius: '4px',
|
||||
width: pct + '%',
|
||||
background: fail > 0 ? 'var(--warning)' : 'var(--success)',
|
||||
transition: 'width 200ms ease'
|
||||
}
|
||||
});
|
||||
barOuter.appendChild(barInner);
|
||||
T.el.progress.appendChild(barOuter);
|
||||
|
||||
var label = $('div', { style: { fontSize: '12px', color: 'var(--text-2)' } },
|
||||
(T.running ? 'Running... ' : '') + total + ' tests · ' + pass + ' pass · ' + fail + ' fail' + (skip > 0 ? ' · ' + skip + ' skip' : '') + ' · ' + pct + '%');
|
||||
T.el.progress.appendChild(label);
|
||||
}
|
||||
|
||||
T.renderSummary = function () {
|
||||
if (!T.el.summary) return;
|
||||
T.el.summary.innerHTML = '';
|
||||
if (T.results.length === 0) return;
|
||||
|
||||
// Group by domain
|
||||
var domains = {};
|
||||
T.results.forEach(function (r) {
|
||||
var k = r.domain;
|
||||
if (!domains[k]) domains[k] = { pass: 0, fail: 0, skip: 0, total: 0, ms: 0 };
|
||||
domains[k].total++;
|
||||
domains[k].ms += r.duration;
|
||||
if (r.status === 'pass') domains[k].pass++;
|
||||
else if (r.status === 'skip') domains[k].skip++;
|
||||
else domains[k].fail++;
|
||||
});
|
||||
|
||||
var grid = $('div', {
|
||||
style: {
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||||
gap: '8px'
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(domains).forEach(function (d) {
|
||||
var s = domains[d];
|
||||
var borderColor = s.fail > 0 ? 'var(--danger)' : (s.skip > 0 ? 'var(--warning)' : 'var(--success)');
|
||||
var label = s.pass + '/' + s.total + ' pass';
|
||||
if (s.skip > 0) label += ' · ' + s.skip + ' skip';
|
||||
label += ' · ' + s.ms + 'ms';
|
||||
var card = $('div', {
|
||||
style: {
|
||||
background: 'var(--bg-surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)', padding: '10px 14px',
|
||||
borderLeft: '3px solid ' + borderColor
|
||||
}
|
||||
}, [
|
||||
$('div', { style: { fontSize: '13px', fontWeight: '600', color: 'var(--text)', marginBottom: '4px' } }, d),
|
||||
$('div', { style: { fontSize: '12px', color: 'var(--text-2)' } }, label)
|
||||
]);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
T.el.summary.appendChild(grid);
|
||||
}
|
||||
|
||||
T.renderDetail = function () {
|
||||
if (!T.el.detail) return;
|
||||
T.el.detail.innerHTML = '';
|
||||
if (T.results.length === 0) return;
|
||||
|
||||
var table = $('table', {
|
||||
style: {
|
||||
width: '100%', borderCollapse: 'collapse', fontSize: '13px',
|
||||
fontFamily: 'var(--mono)', background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--radius)'
|
||||
}
|
||||
});
|
||||
|
||||
// Header
|
||||
var thead = $('thead');
|
||||
var trh = $('tr', { style: { borderBottom: '1px solid var(--border)' } });
|
||||
['', 'Tier', 'Domain', 'Test', 'ms', 'Detail'].forEach(function (h) {
|
||||
trh.appendChild($('th', {
|
||||
style: {
|
||||
textAlign: 'left', padding: '8px 10px', fontSize: '11px',
|
||||
fontWeight: '600', color: 'var(--text-3)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px'
|
||||
}
|
||||
}, h));
|
||||
});
|
||||
thead.appendChild(trh);
|
||||
table.appendChild(thead);
|
||||
|
||||
var tbody = $('tbody');
|
||||
T.results.forEach(function (r) {
|
||||
var isPass = r.status === 'pass';
|
||||
var isSkip = r.status === 'skip';
|
||||
var rowBg = isPass ? 'transparent' : (isSkip ? 'rgba(234,179,8,0.04)' : 'rgba(239,68,68,0.04)');
|
||||
var dotColor = isPass ? 'var(--success)' : (isSkip ? 'var(--warning)' : 'var(--danger)');
|
||||
var detailColor = isPass ? 'var(--text-3)' : (isSkip ? 'var(--warning)' : 'var(--danger)');
|
||||
var tr = $('tr', {
|
||||
style: {
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: rowBg
|
||||
}
|
||||
});
|
||||
|
||||
// Status dot
|
||||
tr.appendChild($('td', { style: { padding: '6px 10px', width: '20px' } }, [
|
||||
$('span', {
|
||||
style: {
|
||||
display: 'inline-block', width: '8px', height: '8px',
|
||||
borderRadius: '50%', background: dotColor
|
||||
}
|
||||
})
|
||||
]));
|
||||
|
||||
tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text-3)' } }, r.tier));
|
||||
tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--accent)' } }, r.domain));
|
||||
tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text)' } }, r.name));
|
||||
tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text-3)', textAlign: 'right' } }, String(r.duration)));
|
||||
|
||||
var detailText = isSkip ? ('SKIP: ' + (r.detail || '')) : (r.detail || '');
|
||||
var detailTd = $('td', {
|
||||
style: {
|
||||
padding: '6px 10px', color: detailColor,
|
||||
maxWidth: '320px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
cursor: detailText ? 'pointer' : 'default'
|
||||
},
|
||||
title: detailText || ''
|
||||
}, detailText ? detailText.substring(0, 120) : '✓');
|
||||
|
||||
if (detailText && detailText.length > 120) {
|
||||
detailTd.addEventListener('click', function () {
|
||||
if (detailTd.style.whiteSpace === 'nowrap') {
|
||||
detailTd.style.whiteSpace = 'pre-wrap';
|
||||
detailTd.style.wordBreak = 'break-all';
|
||||
detailTd.textContent = detailText;
|
||||
} else {
|
||||
detailTd.style.whiteSpace = 'nowrap';
|
||||
detailTd.textContent = r.detail.substring(0, 120);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tr.appendChild(detailTd);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
|
||||
T.el.detail.appendChild(table);
|
||||
}
|
||||
|
||||
})();
|
||||
9
packages/icd-test-runner/manifest.json
Normal file
9
packages/icd-test-runner/manifest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "icd-test-runner",
|
||||
"title": "ICD Test Runner",
|
||||
"route": "/s/icd-test-runner",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"version": "0.28.7.0",
|
||||
"description": "Integration test runner — validates ICD endpoint contracts across smoke, CRUD, AuthZ, Security, Provider, and SDK tiers."
|
||||
}
|
||||
Reference in New Issue
Block a user