|
1 | | -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; |
| 1 | +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; |
2 | 2 | import { tmpdir } from 'node:os'; |
3 | 3 | import { join } from 'node:path'; |
4 | 4 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
5 | 5 | import { |
6 | 6 | appendMessage, |
| 7 | + deleteSession, |
7 | 8 | listSessions, |
8 | 9 | newSessionId, |
9 | 10 | readMessages, |
@@ -259,3 +260,56 @@ describe('session storage', () => { |
259 | 260 | await expect(readFile(files.jsonlPath, 'utf8')).rejects.toThrow(); |
260 | 261 | }); |
261 | 262 | }); |
| 263 | + |
| 264 | +// `deleteSession` ends in a recursive `rm` of `<root>/<id>`, so the id is a path |
| 265 | +// segment resolved from a string the caller received. AGENTS.md asks for |
| 266 | +// adversarial tests where a mistake destroys user work; this is that place. |
| 267 | +describe('deleteSession', () => { |
| 268 | + let root: string; |
| 269 | + beforeEach(async () => { |
| 270 | + root = await mkdtemp(join(tmpdir(), 'deepcode-delete-')); |
| 271 | + }); |
| 272 | + afterEach(async () => { |
| 273 | + await rm(root, { recursive: true, force: true }); |
| 274 | + }); |
| 275 | + |
| 276 | + it('removes every file the session owns', async () => { |
| 277 | + const id = newSessionId(); |
| 278 | + await writeMeta(root, { id, cwd: '/w', createdAt: 'x', updatedAt: 'x' }); |
| 279 | + await appendMessage(root, id, { role: 'user', content: [{ type: 'text', text: 'hi' }] }); |
| 280 | + const files = sessionFiles(root, id); |
| 281 | + |
| 282 | + await deleteSession(root, id); |
| 283 | + |
| 284 | + await expect(readFile(files.metaPath, 'utf8')).rejects.toThrow(); |
| 285 | + await expect(readFile(files.jsonlPath, 'utf8')).rejects.toThrow(); |
| 286 | + expect(await listSessions(root)).toHaveLength(0); |
| 287 | + }); |
| 288 | + |
| 289 | + it('is not an error when the files are already gone', async () => { |
| 290 | + await expect(deleteSession(root, newSessionId())).resolves.toBeUndefined(); |
| 291 | + }); |
| 292 | + |
| 293 | + it.each(['..', '.', '', '../..', 'a/b', '../escape'])( |
| 294 | + 'refuses the id %j instead of resolving it into a path', |
| 295 | + async (id) => { |
| 296 | + // `..` is the one that matters: it is spelled entirely in characters an |
| 297 | + // id may legitimately contain, so a character-class check alone admits |
| 298 | + // it — and `join(sessionsRoot, '..')` is the directory above it. |
| 299 | + // |
| 300 | + // The sessions root is nested deep enough that every id here still |
| 301 | + // resolves inside `root`. Without that, a run with the guard removed |
| 302 | + // walks `rm -rf` up out of the fixture and into the system temp |
| 303 | + // directory — which is how this was confirmed, and not something a test |
| 304 | + // should be able to do by accident. |
| 305 | + const sessionsRoot = join(root, 'deep', 'deeper', 'sessions'); |
| 306 | + await mkdir(sessionsRoot, { recursive: true }); |
| 307 | + const guarded = join(root, 'keep-me.txt'); |
| 308 | + await writeFile(guarded, 'not mine to delete', 'utf8'); |
| 309 | + |
| 310 | + await expect(deleteSession(sessionsRoot, id)).rejects.toThrow(/invalid id/); |
| 311 | + |
| 312 | + expect(await readFile(guarded, 'utf8')).toBe('not mine to delete'); |
| 313 | + }, |
| 314 | + ); |
| 315 | +}); |
0 commit comments