hub-integration-guides.test.mjs
297 lines 11.3 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Integration guide module — 7-tier tests (unit through security).
3 */
4 import { describe, it } from 'node:test';
5 import assert from 'node:assert/strict';
6 import { readFileSync } from 'node:fs';
7 import { fileURLToPath } from 'node:url';
8 import { dirname, join } from 'node:path';
9 import {
10 INTEGRATION_GUIDES,
11 getIntegrationGuide,
12 listIntegrationGuideIds,
13 renderIntegrationGuideHtml,
14 escapeHtml,
15 wireIntegrationTiles,
16 } from '../web/hub/hub-integration-guides.mjs';
17
18 const root = join(dirname(fileURLToPath(import.meta.url)), '..');
19 const hubIndex = readFileSync(join(root, 'web/hub/index.html'), 'utf8');
20 const hubJs = readFileSync(join(root, 'web/hub/hub.js'), 'utf8');
21
22 describe('hub-integration-guides — unit', () => {
23 it('lists every expected capture and import tile id', () => {
24 const ids = listIntegrationGuideIds();
25 for (const id of [
26 'slack',
27 'discord',
28 'telegram',
29 'whatsapp',
30 'chatgpt-export',
31 'claude-export',
32 'openclaw',
33 'hermes',
34 'imports',
35 ]) {
36 assert.ok(ids.includes(id), `missing guide: ${id}`);
37 }
38 });
39
40 it('getIntegrationGuide returns null for unknown ids', () => {
41 assert.equal(getIntegrationGuide(''), null);
42 assert.equal(getIntegrationGuide('not-a-source'), null);
43 });
44
45 it('hermes guide includes export and markdown import commands', () => {
46 const g = getIntegrationGuide('hermes');
47 assert.ok(g);
48 const code = g.sections.filter((s) => s.type === 'code').map((s) => s.code).join('\n');
49 assert.match(code, /hermes memory export/);
50 assert.match(code, /knowtation import markdown/);
51 assert.match(code, /MEMORY\.md/);
52 });
53
54 it('imports guide merges local and team paths', () => {
55 const g = getIntegrationGuide('imports');
56 assert.ok(g);
57 assert.equal(g.name, 'Imports');
58 const text = g.sections.filter((s) => s.type === 'text').map((s) => s.html).join(' ');
59 assert.match(text, /Local/i);
60 assert.match(text, /Team/i);
61 });
62
63 it('escapeHtml neutralizes script injection in code blocks', () => {
64 const out = escapeHtml('<script>alert(1)</script>');
65 assert.equal(out, '&lt;script&gt;alert(1)&lt;/script&gt;');
66 });
67
68 it('renderIntegrationGuideHtml escapes code but preserves author text html', () => {
69 const g = getIntegrationGuide('slack');
70 assert.ok(g);
71 const html = renderIntegrationGuideHtml(g);
72 assert.match(html, /integ-guide-code-block/);
73 assert.doesNotMatch(html, /<script>/i);
74 assert.match(html, /CAPTURE_URL/);
75 });
76 });
77
78 describe('hub-integration-guides — integration', () => {
79 it('wireIntegrationTiles invokes onOpen with the matching guide', () => {
80 const rootEl = {
81 querySelectorAll(sel) {
82 if (sel !== '[data-integ-id]') return [];
83 return [{ getAttribute: () => 'hermes', addEventListener: (_ev, fn) => { rootEl._fn = fn; } }];
84 },
85 _fn: null,
86 };
87 let opened = null;
88 wireIntegrationTiles(rootEl, {
89 onOpen(g) {
90 opened = g;
91 },
92 });
93 assert.equal(typeof rootEl._fn, 'function');
94 rootEl._fn();
95 assert.equal(opened?.id, 'hermes');
96 });
97
98 it('hub index tiles use data-integ-id for every integration button', () => {
99 assert.match(hubIndex, /data-integ-id="hermes"/);
100 assert.match(hubIndex, /data-integ-id="openclaw"/);
101 assert.match(hubIndex, /data-integ-id="imports"/);
102 assert.doesNotMatch(hubIndex, /Local Imports/);
103 assert.doesNotMatch(hubIndex, /Team Imports/);
104 const tileButtons = hubIndex.match(/class="integ-source-tile"/g) || [];
105 assert.ok(tileButtons.length >= 20, 'expected many clickable integration tiles');
106 });
107
108 it('hub.js wires integration guide modal with document delegation', () => {
109 assert.match(hubJs, /HubIntegrationGuides/);
110 assert.match(hubJs, /openIntegGuideModal/);
111 assert.match(hubJs, /modal-integ-guide/);
112 assert.match(hubJs, /closeIntegGuideModal/);
113 assert.match(hubJs, /bindIntegrationGuideModalControlsOnce/);
114 assert.match(hubJs, /#settings-panel-integrations \[data-integ-id\]/);
115 });
116
117 it('integration guide modal stacks above settings modal', () => {
118 const hubCss = readFileSync(join(root, 'web/hub/hub.css'), 'utf8');
119 assert.match(hubCss, /#modal-integ-guide\s*\{\s*z-index:\s*110/);
120 const integIdx = hubIndex.indexOf('id="modal-integ-guide"');
121 const settingsIdx = hubIndex.indexOf('id="modal-settings"');
122 assert.ok(integIdx > settingsIdx, 'integ guide modal should follow settings modal in DOM');
123 });
124 });
125
126 describe('hub-integration-guides — end-to-end (static contract)', () => {
127 it('each HTML data-integ-id resolves to a guide with matching name', () => {
128 const ids = [...hubIndex.matchAll(/data-integ-id="([^"]+)"/g)].map((m) => m[1]);
129 const integIds = ids.filter((id, i, arr) => arr.indexOf(id) === i && INTEGRATION_GUIDES[id]);
130 assert.ok(integIds.length >= 18);
131 for (const id of integIds) {
132 const g = getIntegrationGuide(id);
133 assert.ok(g, id);
134 assert.ok(g.name);
135 assert.ok(g.sections.length > 0);
136 }
137 });
138
139 it('import-capable guides with hub dropdown options expose sourceType', () => {
140 const chatgpt = getIntegrationGuide('chatgpt-export');
141 assert.equal(chatgpt?.sourceType, 'chatgpt-export');
142 assert.equal(chatgpt?.hubImport, true);
143 });
144 });
145
146 describe('hub-integration-guides — stress', () => {
147 it('renderIntegrationGuideHtml handles repeated calls without growth errors', () => {
148 const g = getIntegrationGuide('wallet-csv');
149 assert.ok(g);
150 let last = '';
151 for (let i = 0; i < 200; i++) {
152 last = renderIntegrationGuideHtml(g);
153 }
154 assert.match(last, /wallet-csv/);
155 });
156
157 it('listIntegrationGuideIds returns stable count under repeated reads', () => {
158 const counts = new Set();
159 for (let i = 0; i < 50; i++) counts.add(listIntegrationGuideIds().length);
160 assert.equal(counts.size, 1);
161 assert.ok([...counts][0] >= 20);
162 });
163 });
164
165 describe('hub-integration-guides — data-integrity', () => {
166 it('INTEGRATION_GUIDES is frozen and guides are not mutated by getters', () => {
167 assert.throws(() => {
168 /** @type {Record<string, unknown>} */ (INTEGRATION_GUIDES).new = {};
169 });
170 const before = getIntegrationGuide('hermes');
171 assert.ok(before);
172 const copy = { ...before, name: 'mutated' };
173 assert.notEqual(getIntegrationGuide('hermes')?.name, copy.name);
174 });
175
176 it('render output does not embed raw user-supplied angle brackets from code sections', () => {
177 const fake = {
178 id: 'x',
179 icon: '!',
180 name: 'X',
181 desc: 'd',
182 kind: 'import',
183 sections: [{ type: 'code', code: 'foo <bar> baz' }],
184 };
185 const html = renderIntegrationGuideHtml(fake);
186 assert.match(html, /foo &lt;bar&gt; baz/);
187 assert.doesNotMatch(html, /foo <bar>/);
188 });
189 });
190
191 describe('hub-integration-guides — performance', () => {
192 it('renders all guides in under 500ms total', () => {
193 const t0 = performance.now();
194 for (const id of listIntegrationGuideIds()) {
195 renderIntegrationGuideHtml(getIntegrationGuide(id));
196 }
197 const elapsed = performance.now() - t0;
198 assert.ok(elapsed < 500, `render all guides took ${elapsed}ms`);
199 });
200 });
201
202 describe('hub-integration-guides — security', () => {
203 it('code copy buttons store escaped attribute payloads only', () => {
204 const fake = {
205 id: 'x',
206 icon: '!',
207 name: 'X',
208 desc: 'd',
209 kind: 'import',
210 sections: [{ type: 'code', code: 'echo "test"' }],
211 };
212 const html = renderIntegrationGuideHtml(fake);
213 assert.match(html, /data-copy="echo &quot;test&quot;"/);
214 });
215
216 it('does not document pasting secrets into Hub UI for supabase', () => {
217 const g = getIntegrationGuide('supabase-memory');
218 assert.ok(g);
219 const blob = g.sections.map((s) => (s.type === 'code' ? s.code : s.html)).join('\n');
220 assert.match(blob, /never paste secrets/i);
221 });
222
223 it('hermes doc link uses https official docs', () => {
224 const g = getIntegrationGuide('hermes');
225 assert.ok(g?.docUrl?.startsWith('https://'));
226 });
227 });
228
229 describe('OpenRouter model-provider guide + chat-provider UI', () => {
230 it('unit: openrouter guide exists as a provider-kind tile with an https doc link', () => {
231 const g = getIntegrationGuide('openrouter');
232 assert.ok(g, 'openrouter guide must exist');
233 assert.equal(g.id, 'openrouter');
234 assert.equal(g.name, 'OpenRouter');
235 assert.equal(g.kind, 'provider');
236 assert.ok(g.docUrl?.startsWith('https://'));
237 assert.ok(g.sections.length > 0);
238 });
239
240 it('unit: guide documents the BYO-key env wiring and no managed fallback', () => {
241 const g = getIntegrationGuide('openrouter');
242 const code = g.sections.filter((s) => s.type === 'code').map((s) => s.code).join('\n');
243 const text = g.sections.filter((s) => s.type === 'text').map((s) => s.html).join(' ');
244 assert.match(code, /KNOWTATION_CHAT_PROVIDER=openrouter/);
245 assert.match(code, /OPENROUTER_API_KEY=/);
246 assert.match(text, /bring-your-own-key|BYO/i);
247 assert.match(text, /never (silently )?re-routed|never metered/i);
248 });
249
250 it('integration: the integrations panel renders an OpenRouter tile', () => {
251 assert.match(hubIndex, /data-integ-id="openrouter"/);
252 const tileIdx = hubIndex.indexOf('data-integ-id="openrouter"');
253 const panelIdx = hubIndex.indexOf('id="settings-panel-integrations"');
254 assert.ok(tileIdx > panelIdx, 'OpenRouter tile must live in the integrations panel');
255 });
256
257 it('integration: chat-provider selector exposes every selectable provider plus auto-detect', () => {
258 const selStart = hubIndex.indexOf('id="chat-provider-select"');
259 assert.notEqual(selStart, -1, 'chat-provider-select must exist');
260 const selBlock = hubIndex.slice(selStart, hubIndex.indexOf('</select>', selStart));
261 for (const v of ['value=""', 'value="openai"', 'value="anthropic"', 'value="deepinfra"', 'value="openrouter"', 'value="ollama"']) {
262 assert.ok(selBlock.includes(v), `chat-provider-select missing option ${v}`);
263 }
264 });
265
266 it('integration: hub.js loads, gates, and saves the chat provider via the settings API', () => {
267 assert.match(hubJs, /function applyChatProviderSettings/);
268 assert.match(hubJs, /applyChatProviderSettings\(s\)/);
269 assert.match(hubJs, /\/api\/v1\/settings\/chat/);
270 assert.match(hubJs, /method: 'POST'/);
271 });
272
273 it('security: env lock + admin gating are honoured in the UI load path', () => {
274 assert.match(hubJs, /chat\.env_locked/);
275 assert.match(hubJs, /KNOWTATION_CHAT_PROVIDER/);
276 assert.match(hubJs, /String\(s && s\.role\) === 'admin'/);
277 // disabled when env-locked or non-admin
278 assert.match(hubJs, /sel\.disabled = envLocked \|\| !isAdmin/);
279 });
280
281 it('security: guide keeps the API key in server env, not the Hub UI', () => {
282 const g = getIntegrationGuide('openrouter');
283 const text = g.sections.filter((s) => s.type === 'text').map((s) => s.html).join(' ');
284 assert.match(text, /server env/i);
285 // No real-looking key is embedded in the guide content.
286 const blob = g.sections.map((s) => (s.type === 'code' ? s.code : s.html)).join('\n');
287 assert.doesNotMatch(blob, /sk-or-v1-[A-Za-z0-9]{20,}/);
288 });
289
290 it('end-to-end: the OpenRouter tile resolves through the same modal contract', () => {
291 const html = renderIntegrationGuideHtml(getIntegrationGuide('openrouter'));
292 assert.match(html, /integ-guide-code-block/);
293 assert.match(html, /KNOWTATION_CHAT_PROVIDER=openrouter/);
294 assert.doesNotMatch(html, /<script>/i);
295 assert.match(html, /href="https:\/\/openrouter\.ai/);
296 });
297 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago