/ src / lib / server / storage-item-access.test.ts
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  })