hub-integration-guides.test.mjs
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, '<script>alert(1)</script>'); |
| 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 <bar> 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 "test""/); |
| 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