storage-item-access.test.ts
1 import assert from 'node:assert/strict' 2 import fs from 'node:fs' 3 import os from 'node:os' 4 import path from 'node:path' 5 import { spawnSync } from 'node:child_process' 6 import test from 'node:test' 7 8 const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..') 9 10 function runWithTempDataDir(script: string) { 11 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-storage-items-')) 12 try { 13 const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], { 14 cwd: repoRoot, 15 env: { 16 ...process.env, 17 DATA_DIR: path.join(tempDir, 'data'), 18 WORKSPACE_DIR: path.join(tempDir, 'workspace'), 19 BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'), 20 }, 21 encoding: 'utf-8', 22 }) 23 assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed') 24 const lines = (result.stdout || '') 25 .trim() 26 .split('\n') 27 .map((line) => line.trim()) 28 .filter(Boolean) 29 const jsonLine = [...lines].reverse().find((line) => line.startsWith('{')) 30 return JSON.parse(jsonLine || '{}') 31 } finally { 32 fs.rmSync(tempDir, { recursive: true, force: true }) 33 } 34 } 35 36 test('item-level storage helpers load and patch sessions and tasks', () => { 37 const output = runWithTempDataDir(` 38 const storageMod = await import('./src/lib/server/storage') 39 const storage = storageMod.default || storageMod['module.exports'] || storageMod 40 41 const now = Date.now() 42 storage.upsertSession('session-item', { 43 id: 'session-item', 44 name: 'Item Test', 45 cwd: '/tmp', 46 user: 'tester', 47 provider: 'claude-cli', 48 model: '', 49 claudeSessionId: null, 50 codexThreadId: null, 51 opencodeSessionId: null, 52 messages: [{ role: 'user', text: 'hello', time: now }], 53 createdAt: now, 54 lastActiveAt: now, 55 sessionType: 'human', 56 }) 57 58 const loadedSession = storage.loadSession('session-item') 59 storage.patchSession('session-item', (current) => { 60 current.messages.push({ role: 'assistant', text: 'hi', time: now + 1 }) 61 current.lastActiveAt = now + 1 62 return current 63 }) 64 const patchedSession = storage.loadSession('session-item') 65 66 storage.upsertTask('task-item', { 67 id: 'task-item', 68 title: 'Patch me', 69 status: 'queued', 70 agentId: 'default', 71 createdAt: now, 72 updatedAt: now, 73 }) 74 75 const loadedTask = storage.loadTask('task-item') 76 storage.patchTask('task-item', (current) => { 77 current.status = 'completed' 78 current.updatedAt = now + 2 79 current.result = 'done' 80 return current 81 }) 82 const patchedTask = storage.loadTask('task-item') 83 84 console.log(JSON.stringify({ 85 loadedSessionCount: loadedSession?.messages?.length || 0, 86 patchedSessionCount: patchedSession?.messages?.length || 0, 87 patchedSessionLastText: patchedSession?.messages?.at(-1)?.text || null, 88 loadedTaskStatus: loadedTask?.status || null, 89 patchedTaskStatus: patchedTask?.status || null, 90 patchedTaskResult: patchedTask?.result || null, 91 })) 92 `) 93 94 assert.equal(output.loadedSessionCount, 1) 95 assert.equal(output.patchedSessionCount, 2) 96 assert.equal(output.patchedSessionLastText, 'hi') 97 assert.equal(output.loadedTaskStatus, 'queued') 98 assert.equal(output.patchedTaskStatus, 'completed') 99 assert.equal(output.patchedTaskResult, 'done') 100 }) 101 102 test('TTL-backed storage loaders return defensive clones on cold reads', () => { 103 const output = runWithTempDataDir(` 104 const storageMod = await import('./src/lib/server/storage') 105 const storage = storageMod.default || storageMod['module.exports'] || storageMod 106 107 storage.saveCredentials({ 108 cred_1: { 109 id: 'cred_1', 110 name: 'Original credential', 111 encryptedKey: 'ciphertext', 112 }, 113 }) 114 storage.saveGatewayProfiles({ 115 gateway_1: { 116 id: 'gateway_1', 117 name: 'Primary gateway', 118 baseUrl: 'http://localhost:3456', 119 }, 120 }) 121 storage.saveConnectors({ 122 connector_1: { 123 id: 'connector_1', 124 name: 'Primary connector', 125 platform: 'discord', 126 }, 127 }) 128 129 const coldCredentials = storage.loadCredentials() 130 coldCredentials.cred_1.name = 'Mutated credential' 131 132 const coldGateways = storage.loadGatewayProfiles() 133 coldGateways.gateway_1.name = 'Mutated gateway' 134 135 const coldConnectors = storage.loadConnectors() 136 coldConnectors.connector_1.name = 'Mutated connector' 137 138 const reloadedCredentials = storage.loadCredentials() 139 const reloadedGateways = storage.loadGatewayProfiles() 140 const reloadedConnectors = storage.loadConnectors() 141 142 console.log(JSON.stringify({ 143 credentialName: reloadedCredentials.cred_1?.name || null, 144 gatewayName: reloadedGateways.gateway_1?.name || null, 145 connectorName: reloadedConnectors.connector_1?.name || null, 146 })) 147 `) 148 149 assert.equal(output.credentialName, 'Original credential') 150 assert.equal(output.gatewayName, 'Primary gateway') 151 assert.equal(output.connectorName, 'Primary connector') 152 }) 153 154 test('item-level upserts invalidate TTL-backed collection loaders', () => { 155 const output = runWithTempDataDir(` 156 const storageMod = await import('./src/lib/server/storage') 157 const storage = storageMod.default || storageMod['module.exports'] || storageMod 158 159 storage.saveConnectors({ 160 connector_1: { 161 id: 'connector_1', 162 name: 'Primary connector', 163 platform: 'discord', 164 }, 165 }) 166 167 const warmed = storage.loadConnectors() 168 const beforeKeys = Object.keys(warmed).sort() 169 170 storage.upsertStoredItem('connectors', 'connector_2', { 171 id: 'connector_2', 172 name: 'Secondary connector', 173 platform: 'discord', 174 }) 175 176 const afterKeys = Object.keys(storage.loadConnectors()).sort() 177 178 console.log(JSON.stringify({ 179 beforeKeys, 180 afterKeys, 181 connector2Name: storage.loadConnectors().connector_2?.name || null, 182 })) 183 `) 184 185 assert.deepEqual(output.beforeKeys, ['connector_1']) 186 assert.deepEqual(output.afterKeys, ['connector_1', 'connector_2']) 187 assert.equal(output.connector2Name, 'Secondary connector') 188 }) 189 190 test('queue patching, runtime locks, and usage spend queries are transactional', () => { 191 const output = runWithTempDataDir(` 192 const storageMod = await import('./src/lib/server/storage') 193 const storage = storageMod.default || storageMod['module.exports'] || storageMod 194 195 const firstQueueSize = storage.patchQueue((queue) => { 196 queue.push('task-a') 197 queue.push('task-b') 198 return queue.length 199 }) 200 storage.patchQueue((queue) => { 201 queue.splice(0, 1) 202 return queue.slice() 203 }) 204 205 const firstLock = storage.tryAcquireRuntimeLock('task-queue', 'owner-a', 50) 206 const secondLockWhileHeld = storage.tryAcquireRuntimeLock('task-queue', 'owner-b', 50) 207 const renewedOwnerA = storage.renewRuntimeLock('task-queue', 'owner-a', 50) 208 let secondLockAfterExpiry = false 209 const expiryDeadline = Date.now() + 1000 210 while (!secondLockAfterExpiry && Date.now() < expiryDeadline) { 211 await new Promise((resolve) => setTimeout(resolve, 40)) 212 secondLockAfterExpiry = storage.tryAcquireRuntimeLock('task-queue', 'owner-b', 50) 213 } 214 const renewedOwnerB = storage.renewRuntimeLock('task-queue', 'owner-b', 50) 215 storage.releaseRuntimeLock('task-queue', 'owner-b') 216 const thirdLockAfterRelease = storage.tryAcquireRuntimeLock('task-queue', 'owner-c', 40) 217 218 const dayStart = new Date() 219 dayStart.setHours(0, 0, 0, 0) 220 const minTs = dayStart.getTime() 221 storage.appendUsage('session-a', { timestamp: minTs - 1000, estimatedCost: 5 }) 222 storage.appendUsage('session-a', { timestamp: minTs + 1000, estimatedCost: 1.25 }) 223 storage.appendUsage('session-b', { timestamp: minTs + 2000, estimatedCost: 2.5 }) 224 225 console.log(JSON.stringify({ 226 firstQueueSize, 227 queueAfterPatch: storage.loadQueue(), 228 firstLock, 229 secondLockWhileHeld, 230 renewedOwnerA, 231 secondLockAfterExpiry, 232 renewedOwnerB, 233 thirdLockAfterRelease, 234 spendSinceDayStart: storage.getUsageSpendSince(minTs), 235 })) 236 `) 237 238 assert.equal(output.firstQueueSize, 2) 239 assert.deepEqual(output.queueAfterPatch, ['task-b']) 240 assert.equal(output.firstLock, true) 241 assert.equal(output.secondLockWhileHeld, false) 242 assert.equal(output.renewedOwnerA, true) 243 assert.equal(output.secondLockAfterExpiry, true) 244 assert.equal(output.renewedOwnerB, true) 245 assert.equal(output.thirdLockAfterRelease, true) 246 assert.equal(output.spendSinceDayStart, 3.75) 247 }) 248 249 test('row-level agent, schedule, and task helpers update one record without losing siblings', () => { 250 const output = runWithTempDataDir(` 251 const storageMod = await import('./src/lib/server/storage') 252 const storage = storageMod.default || storageMod['module.exports'] || storageMod 253 254 const now = Date.now() 255 storage.saveAgents({ 256 'agent-a': { id: 'agent-a', name: 'Agent A', createdAt: now, updatedAt: now }, 257 'agent-b': { id: 'agent-b', name: 'Agent B', createdAt: now, updatedAt: now }, 258 }) 259 storage.saveSchedules({ 260 'schedule-a': { id: 'schedule-a', name: 'Schedule A', status: 'active', createdAt: now, updatedAt: now }, 261 'schedule-b': { id: 'schedule-b', name: 'Schedule B', status: 'paused', createdAt: now, updatedAt: now }, 262 }) 263 storage.saveTasks({ 264 'task-a': { id: 'task-a', title: 'Task A', status: 'backlog', agentId: 'agent-a', createdAt: now, updatedAt: now }, 265 'task-b': { id: 'task-b', title: 'Task B', status: 'queued', agentId: 'agent-b', createdAt: now, updatedAt: now }, 266 }) 267 268 // Warm the non-trashed agent cache before the upsert so the test verifies invalidation. 269 storage.loadAgents() 270 271 storage.upsertAgent('agent-a', { id: 'agent-a', name: 'Agent A Updated', createdAt: now, updatedAt: now + 1 }) 272 storage.upsertSchedule('schedule-a', { id: 'schedule-a', name: 'Schedule A', status: 'completed', createdAt: now, updatedAt: now + 1 }) 273 storage.upsertTasks([ 274 ['task-a', { id: 'task-a', title: 'Task A', status: 'completed', agentId: 'agent-a', createdAt: now, updatedAt: now + 1 }], 275 ]) 276 277 const agents = storage.loadAgents() 278 const schedules = storage.loadSchedules() 279 const tasks = storage.loadTasks() 280 281 console.log(JSON.stringify({ 282 agentNames: Object.keys(agents).sort().map((id) => agents[id].name), 283 scheduleIds: Object.keys(schedules).sort(), 284 taskIds: Object.keys(tasks).sort(), 285 updatedAgentName: storage.loadAgent('agent-a')?.name || null, 286 updatedScheduleStatus: storage.loadSchedule('schedule-a')?.status || null, 287 updatedTaskStatus: storage.loadTask('task-a')?.status || null, 288 })) 289 `) 290 291 assert.ok(output.agentNames.includes('Agent A Updated'), 'agent-a should be updated') 292 assert.ok(output.agentNames.includes('Agent B'), 'agent-b should still exist') 293 assert.deepEqual(output.scheduleIds, ['schedule-a', 'schedule-b']) 294 assert.deepEqual(output.taskIds, ['task-a', 'task-b']) 295 assert.equal(output.updatedAgentName, 'Agent A Updated') 296 assert.equal(output.updatedScheduleStatus, 'completed') 297 assert.equal(output.updatedTaskStatus, 'completed') 298 }) 299 300 // --------------------------------------------------------------------------- 301 // Reliability fix #11: requireCredentialSecret validation 302 // --------------------------------------------------------------------------- 303 304 test('encryptKey throws a clear message when CREDENTIAL_SECRET is unset', () => { 305 // Use SWARMCLAW_BUILD_MODE=1 to skip auto-generation of CREDENTIAL_SECRET, 306 // then verify encryptKey throws with a clear error message. 307 const cleanEnv = { ...process.env } 308 delete cleanEnv.CREDENTIAL_SECRET 309 cleanEnv.SWARMCLAW_BUILD_MODE = '1' 310 311 const tempBase = path.join(os.tmpdir(), 'swarmclaw-cred-test-' + Date.now()) 312 cleanEnv.DATA_DIR = path.join(tempBase, 'data') 313 cleanEnv.WORKSPACE_DIR = path.join(tempBase, 'workspace') 314 315 const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', ` 316 const storageMod = await import('./src/lib/server/storage') 317 const storage = storageMod.default || storageMod['module.exports'] || storageMod 318 319 try { 320 storage.encryptKey('test-plaintext') 321 console.log(JSON.stringify({ error: null })) 322 } catch (err) { 323 console.log(JSON.stringify({ error: err.message })) 324 } 325 `], { 326 cwd: repoRoot, 327 env: cleanEnv, 328 encoding: 'utf-8', 329 }) 330 331 const lines = (result.stdout || '').trim().split('\n').map((l: string) => l.trim()).filter(Boolean) 332 const jsonLine = [...lines].reverse().find((l: string) => l.startsWith('{')) 333 const output = JSON.parse(jsonLine || '{}') 334 335 assert.ok(output.error, 'encryptKey should throw when CREDENTIAL_SECRET is unset') 336 assert.match(output.error, /CREDENTIAL_SECRET/, 'Error message should mention CREDENTIAL_SECRET') 337 338 try { fs.rmSync(tempBase, { recursive: true, force: true }) } catch { /* best-effort */ } 339 }) 340 341 // --------------------------------------------------------------------------- 342 // Regression: saveCredentials and saveAgents must not wipe existing rows 343 // --------------------------------------------------------------------------- 344 345 test('saveCredentials with a partial object does not delete existing credentials', () => { 346 const output = runWithTempDataDir(` 347 const storageMod = await import('./src/lib/server/storage') 348 const storage = storageMod.default || storageMod['module.exports'] || storageMod 349 350 // Seed two credentials 351 storage.saveCredentials({ 352 'cred-a': { id: 'cred-a', name: 'Key A', encryptedKey: 'aaa' }, 353 'cred-b': { id: 'cred-b', name: 'Key B', encryptedKey: 'bbb' }, 354 }) 355 356 const before = Object.keys(storage.loadCredentials()).sort() 357 358 // Save only one credential — must NOT delete cred-b 359 storage.saveCredentials({ 360 'cred-a': { id: 'cred-a', name: 'Key A Updated', encryptedKey: 'aaa2' }, 361 }) 362 363 const after = storage.loadCredentials() 364 const afterKeys = Object.keys(after).sort() 365 366 console.log(JSON.stringify({ 367 before, 368 afterKeys, 369 credAName: after['cred-a']?.name || null, 370 credBSurvived: !!after['cred-b'], 371 })) 372 `) 373 374 assert.deepEqual(output.before, ['cred-a', 'cred-b']) 375 assert.deepEqual(output.afterKeys, ['cred-a', 'cred-b']) 376 assert.equal(output.credAName, 'Key A Updated') 377 assert.equal(output.credBSurvived, true, 'saveCredentials must not delete credentials not in the passed object') 378 }) 379 380 test('saveAgents with a partial object does not delete existing agents', () => { 381 const output = runWithTempDataDir(` 382 const storageMod = await import('./src/lib/server/storage') 383 const storage = storageMod.default || storageMod['module.exports'] || storageMod 384 385 const now = Date.now() 386 storage.saveAgents({ 387 'agent-x': { id: 'agent-x', name: 'Agent X', createdAt: now, updatedAt: now }, 388 'agent-y': { id: 'agent-y', name: 'Agent Y', createdAt: now, updatedAt: now }, 389 }) 390 391 const beforeCount = Object.keys(storage.loadAgents()).length 392 393 // Save only one agent — must NOT delete agent-y 394 storage.saveAgents({ 395 'agent-x': { id: 'agent-x', name: 'Agent X Updated', createdAt: now, updatedAt: now }, 396 }) 397 398 const after = storage.loadAgents() 399 const afterCount = Object.keys(after).length 400 401 console.log(JSON.stringify({ 402 beforeCount, 403 afterCount, 404 agentXName: after['agent-x']?.name || null, 405 agentYSurvived: !!after['agent-y'], 406 })) 407 `) 408 409 assert.ok(output.beforeCount >= 2) 410 assert.equal(output.afterCount, output.beforeCount, 'saveAgents must not delete agents not in the passed object') 411 assert.equal(output.agentXName, 'Agent X Updated') 412 assert.equal(output.agentYSurvived, true) 413 }) 414 415 test('saveCollection safety guard blocks bulk deletion when deletes exceed upserts', () => { 416 const output = runWithTempDataDir(` 417 const storageMod = await import('./src/lib/server/storage') 418 const storage = storageMod.default || storageMod['module.exports'] || storageMod 419 420 // Seed three connectors 421 storage.saveConnectors({ 422 'conn-1': { id: 'conn-1', name: 'C1', platform: 'discord' }, 423 'conn-2': { id: 'conn-2', name: 'C2', platform: 'slack' }, 424 'conn-3': { id: 'conn-3', name: 'C3', platform: 'telegram' }, 425 }) 426 427 const before = Object.keys(storage.loadConnectors()).sort() 428 429 // Pass only 1 item — safety guard should block deleting the other 2 430 storage.saveConnectors({ 431 'conn-1': { id: 'conn-1', name: 'C1 Updated', platform: 'discord' }, 432 }) 433 434 const after = storage.loadConnectors() 435 const afterKeys = Object.keys(after).sort() 436 437 console.log(JSON.stringify({ 438 before, 439 afterKeys, 440 allSurvived: afterKeys.length === 3, 441 })) 442 `) 443 444 assert.deepEqual(output.before, ['conn-1', 'conn-2', 'conn-3']) 445 assert.equal(output.allSurvived, true, 'saveCollection safety guard must block bulk deletion') 446 })