@polderlabs/bizar 4.2.0 → 4.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bizar-dash/src/server/memory-git.mjs +62 -0
- package/bizar-dash/src/server/memory-store.mjs +82 -10
- package/bizar-dash/tests/memory-cli-readlistdelete.test.mjs +405 -0
- package/bizar-dash/tests/memory-cli-setup.test.mjs +382 -0
- package/bizar-dash/tests/memory-cli.test.mjs +54 -0
- package/bizar-dash/tests/memory-conflicts.test.mjs +229 -0
- package/bizar-dash/tests/memory-namespace.test.mjs +404 -0
- package/bizar-dash/tests/memory-path-safety.test.mjs +427 -0
- package/bizar-dash/tests/memory-roundtrip.test.mjs +219 -0
- package/cli/memory.mjs +273 -6
- package/package.json +2 -2
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/memory-path-safety.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for path traversal and injection attacks against resolveSafe() and
|
|
5
|
+
* the writeNote / readNote / deleteNote surface.
|
|
6
|
+
*
|
|
7
|
+
* Gap closed: memory-system-map §8.2 HIGH gap #9 "Path traversal safety" and
|
|
8
|
+
* §9 Layer 1 "resolveSafe() path traversal — ../, null bytes, absolute paths,
|
|
9
|
+
* symlink escapes".
|
|
10
|
+
*
|
|
11
|
+
* Each attack vector is tested against writeNote (should throw), readNote
|
|
12
|
+
* (should return null), and deleteNote (should return false).
|
|
13
|
+
*
|
|
14
|
+
* NOTE: If any of these paths are NOT rejected by the current implementation,
|
|
15
|
+
* that IS a finding — document it here (do NOT fix it; the test documents
|
|
16
|
+
* expected behaviour vs actual behaviour).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { mkdirSync, rmSync, symlinkSync, writeFileSync, readFileSync, existsSync, lstatSync } from 'node:fs';
|
|
24
|
+
|
|
25
|
+
const TEST_MEMORY_STORE = await import('../src/server/memory-store.mjs').then((m) => m);
|
|
26
|
+
|
|
27
|
+
const uid = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
28
|
+
|
|
29
|
+
/** Returns a valid minimal note for passing schema validation. */
|
|
30
|
+
function validNote(overrides = {}) {
|
|
31
|
+
return {
|
|
32
|
+
frontmatter: {
|
|
33
|
+
memory_id: `path-safety-${uid().slice(0, 8)}`,
|
|
34
|
+
type: 'session_summary',
|
|
35
|
+
project_id: 'path-safety-test',
|
|
36
|
+
status: 'active',
|
|
37
|
+
confidence: 'verified',
|
|
38
|
+
created: new Date().toISOString(),
|
|
39
|
+
updated: new Date().toISOString(),
|
|
40
|
+
tags: [],
|
|
41
|
+
title: 'Path safety test',
|
|
42
|
+
...overrides.frontmatter,
|
|
43
|
+
},
|
|
44
|
+
body: 'path safety body content',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('memory-path-safety: resolveSafe() via writeNote', () => {
|
|
49
|
+
let projectRoot;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
projectRoot = join(tmpdir(), `path-safety-${uid()}`);
|
|
53
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
54
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
55
|
+
TEST_MEMORY_STORE.saveConfig(projectRoot, {
|
|
56
|
+
version: 1,
|
|
57
|
+
backend: 'bizar-local',
|
|
58
|
+
projectId: 'path-safety-test',
|
|
59
|
+
memoryRepo: {
|
|
60
|
+
mode: 'local-only',
|
|
61
|
+
path: join(projectRoot, '.obsidian'),
|
|
62
|
+
remote: null,
|
|
63
|
+
branch: 'main',
|
|
64
|
+
namespace: null,
|
|
65
|
+
},
|
|
66
|
+
namespaces: {
|
|
67
|
+
project: 'projects/path-safety-test',
|
|
68
|
+
global: 'global/bizar',
|
|
69
|
+
user: 'users/tester',
|
|
70
|
+
},
|
|
71
|
+
lightrag: { enabled: false, host: '127.0.0.1', port: 9621, workingDir: join(projectRoot, '.bizar', 'lightrag') },
|
|
72
|
+
git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false, commitAuthor: 'Bizar Memory <bizar-memory@local>', commitMessageTemplate: 'memory(path-safety): {summary}' },
|
|
73
|
+
});
|
|
74
|
+
TEST_MEMORY_STORE.initVault(projectRoot);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Each entry: path, whether resolveSafe rejects it (true=rejected/throws),
|
|
82
|
+
// and a note explaining the finding if it's not rejected as expected.
|
|
83
|
+
const attackPaths = [
|
|
84
|
+
{
|
|
85
|
+
label: '../../../etc/passwd',
|
|
86
|
+
path: '../../../etc/passwd',
|
|
87
|
+
shouldReject: true,
|
|
88
|
+
finding: null,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
label: '/etc/passwd (absolute)',
|
|
92
|
+
path: '/etc/passwd',
|
|
93
|
+
shouldReject: true,
|
|
94
|
+
finding: null,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
label: 'notes/../escape.md (mid-path dotdot — now rejected by segment check)',
|
|
98
|
+
path: 'notes/../escape.md',
|
|
99
|
+
shouldReject: true,
|
|
100
|
+
finding: null,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
label: 'notes\\x00null.md (null byte — correctly rejected)',
|
|
104
|
+
path: 'notes\x00null.md',
|
|
105
|
+
shouldReject: true,
|
|
106
|
+
finding: null,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
label: 'notes/../../global/bizar/secret.md (cross-namespace)',
|
|
110
|
+
path: 'notes/../../global/bizar/secret.md',
|
|
111
|
+
shouldReject: true,
|
|
112
|
+
finding: null,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
label: '../projects/other/escape.md',
|
|
116
|
+
path: '../projects/other/escape.md',
|
|
117
|
+
shouldReject: true,
|
|
118
|
+
finding: null,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
label: '..\\..\\etc\\passwd (backslash variant)',
|
|
122
|
+
path: '..\\..\\etc\\passwd',
|
|
123
|
+
shouldReject: true,
|
|
124
|
+
finding: null,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
label: 'global/../../etc/passwd',
|
|
128
|
+
path: 'global/../../etc/passwd',
|
|
129
|
+
shouldReject: true,
|
|
130
|
+
finding: null,
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
for (const { label, path, shouldReject, finding } of attackPaths) {
|
|
135
|
+
if (shouldReject) {
|
|
136
|
+
it(`writeNote REJECTS: ${label}`, () => {
|
|
137
|
+
assert.throws(
|
|
138
|
+
() => TEST_MEMORY_STORE.writeNote(projectRoot, path, validNote()),
|
|
139
|
+
/Invalid note path/i,
|
|
140
|
+
`[${label}] writeNote should throw for path: ${path}`,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
// Document the finding: path is NOT rejected
|
|
145
|
+
it(`FINDING — writeNote ACCEPTS: ${label}`, () => {
|
|
146
|
+
let thrown = false;
|
|
147
|
+
let result;
|
|
148
|
+
try {
|
|
149
|
+
result = TEST_MEMORY_STORE.writeNote(projectRoot, path, validNote());
|
|
150
|
+
} catch (e) {
|
|
151
|
+
thrown = true;
|
|
152
|
+
}
|
|
153
|
+
// This documents the ACTUAL behaviour (not the desired behaviour)
|
|
154
|
+
assert.strictEqual(
|
|
155
|
+
thrown,
|
|
156
|
+
false,
|
|
157
|
+
`[FINDING] ${label}: path was unexpectedly rejected. Update this test if the behaviour changes.`,
|
|
158
|
+
);
|
|
159
|
+
assert.ok(
|
|
160
|
+
result?.relPath,
|
|
161
|
+
`[FINDING] ${label}: writeNote accepted this path; relPath=${result?.relPath}. ${finding}`,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('memory-path-safety: resolveSafe() via readNote', () => {
|
|
169
|
+
let projectRoot;
|
|
170
|
+
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
projectRoot = join(tmpdir(), `path-safety-read-${uid()}`);
|
|
173
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
174
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
175
|
+
TEST_MEMORY_STORE.saveConfig(projectRoot, {
|
|
176
|
+
version: 1,
|
|
177
|
+
backend: 'bizar-local',
|
|
178
|
+
projectId: 'path-safety-test',
|
|
179
|
+
memoryRepo: {
|
|
180
|
+
mode: 'local-only',
|
|
181
|
+
path: join(projectRoot, '.obsidian'),
|
|
182
|
+
remote: null,
|
|
183
|
+
branch: 'main',
|
|
184
|
+
namespace: null,
|
|
185
|
+
},
|
|
186
|
+
namespaces: {
|
|
187
|
+
project: 'projects/path-safety-test',
|
|
188
|
+
global: 'global/bizar',
|
|
189
|
+
user: 'users/tester',
|
|
190
|
+
},
|
|
191
|
+
lightrag: { enabled: false, host: '127.0.0.1', port: 9621, workingDir: join(projectRoot, '.bizar', 'lightrag') },
|
|
192
|
+
git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false, commitAuthor: 'Bizar Memory <bizar-memory@local>', commitMessageTemplate: 'memory(path-safety): {summary}' },
|
|
193
|
+
});
|
|
194
|
+
TEST_MEMORY_STORE.initVault(projectRoot);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
afterEach(() => {
|
|
198
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const attackPaths = [
|
|
202
|
+
{ label: '../etc/passwd', path: '../../../etc/passwd' },
|
|
203
|
+
{ label: '/etc/passwd absolute', path: '/etc/passwd' },
|
|
204
|
+
{ label: 'notes/../escape.md', path: 'notes/../escape.md' },
|
|
205
|
+
{ label: 'null byte injection', path: 'notes\x00null.md' },
|
|
206
|
+
{ label: 'cross-namespace escape', path: 'notes/../../global/bizar/secret.md' },
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
for (const { label, path } of attackPaths) {
|
|
210
|
+
it(`readNote returns null for: ${label}`, () => {
|
|
211
|
+
const result = TEST_MEMORY_STORE.readNote(projectRoot, path);
|
|
212
|
+
assert.strictEqual(result, null, `[${label}] readNote should return null for path: ${path}`);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('memory-path-safety: resolveSafe() via deleteNote', () => {
|
|
218
|
+
let projectRoot;
|
|
219
|
+
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
projectRoot = join(tmpdir(), `path-safety-del-${uid()}`);
|
|
222
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
223
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
224
|
+
TEST_MEMORY_STORE.saveConfig(projectRoot, {
|
|
225
|
+
version: 1,
|
|
226
|
+
backend: 'bizar-local',
|
|
227
|
+
projectId: 'path-safety-test',
|
|
228
|
+
memoryRepo: {
|
|
229
|
+
mode: 'local-only',
|
|
230
|
+
path: join(projectRoot, '.obsidian'),
|
|
231
|
+
remote: null,
|
|
232
|
+
branch: 'main',
|
|
233
|
+
namespace: null,
|
|
234
|
+
},
|
|
235
|
+
namespaces: {
|
|
236
|
+
project: 'projects/path-safety-test',
|
|
237
|
+
global: 'global/bizar',
|
|
238
|
+
user: 'users/tester',
|
|
239
|
+
},
|
|
240
|
+
lightrag: { enabled: false, host: '127.0.0.1', port: 9621, workingDir: join(projectRoot, '.bizar', 'lightrag') },
|
|
241
|
+
git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false, commitAuthor: 'Bizar Memory <bizar-memory@local>', commitMessageTemplate: 'memory(path-safety): {summary}' },
|
|
242
|
+
});
|
|
243
|
+
TEST_MEMORY_STORE.initVault(projectRoot);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
afterEach(() => {
|
|
247
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const attackPaths = [
|
|
251
|
+
{ label: '../etc/passwd', path: '../../../etc/passwd' },
|
|
252
|
+
{ label: '/etc/passwd absolute', path: '/etc/passwd' },
|
|
253
|
+
{ label: 'notes/../escape.md', path: 'notes/../escape.md' },
|
|
254
|
+
{ label: 'null byte injection', path: 'notes\x00null.md' },
|
|
255
|
+
{ label: 'cross-namespace escape', path: 'notes/../../global/bizar/secret.md' },
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
for (const { label, path } of attackPaths) {
|
|
259
|
+
it(`deleteNote returns false for: ${label}`, () => {
|
|
260
|
+
const result = TEST_MEMORY_STORE.deleteNote(projectRoot, path);
|
|
261
|
+
assert.strictEqual(result, false, `[${label}] deleteNote should return false for path: ${path}`);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('memory-path-safety: symlink traversal', () => {
|
|
267
|
+
let projectRoot;
|
|
268
|
+
let vaultRoot;
|
|
269
|
+
|
|
270
|
+
beforeEach(() => {
|
|
271
|
+
projectRoot = join(tmpdir(), `path-safety-sym-${uid()}`);
|
|
272
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
273
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
274
|
+
vaultRoot = join(projectRoot, '.obsidian');
|
|
275
|
+
mkdirSync(vaultRoot, { recursive: true });
|
|
276
|
+
|
|
277
|
+
TEST_MEMORY_STORE.saveConfig(projectRoot, {
|
|
278
|
+
version: 1,
|
|
279
|
+
backend: 'bizar-local',
|
|
280
|
+
projectId: 'path-safety-test',
|
|
281
|
+
memoryRepo: {
|
|
282
|
+
mode: 'local-only',
|
|
283
|
+
path: vaultRoot,
|
|
284
|
+
remote: null,
|
|
285
|
+
branch: 'main',
|
|
286
|
+
namespace: null,
|
|
287
|
+
},
|
|
288
|
+
namespaces: {
|
|
289
|
+
project: 'projects/path-safety-test',
|
|
290
|
+
global: 'global/bizar',
|
|
291
|
+
user: 'users/tester',
|
|
292
|
+
},
|
|
293
|
+
lightrag: { enabled: false, host: '127.0.0.1', port: 9621, workingDir: join(projectRoot, '.bizar', 'lightrag') },
|
|
294
|
+
git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false, commitAuthor: 'Bizar Memory <bizar-memory@local>', commitMessageTemplate: 'memory(path-safety): {summary}' },
|
|
295
|
+
});
|
|
296
|
+
TEST_MEMORY_STORE.initVault(projectRoot);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
afterEach(() => {
|
|
300
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
301
|
+
try { rmSync(join(tmpdir(), `symlink-outside-${uid().slice(0, 8)}.md`), { force: true }); } catch { /* ignore */ }
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('REGRESSION: symlink inside vault pointing to outside file — writeNote now REJECTS (symlink guard)', () => {
|
|
305
|
+
// Symlink guard (Fix 2): resolveSafe() now refuses to write through a
|
|
306
|
+
// pre-existing symlink inside the vault. The previous behaviour followed
|
|
307
|
+
// the symlink, allowing the outside file to be overwritten.
|
|
308
|
+
|
|
309
|
+
const outsideFile = join(tmpdir(), `symlink-outside-${uid()}.md`);
|
|
310
|
+
writeFileSync(outsideFile, 'ORIGINAL CONTENT — should not be overwritten', 'utf8');
|
|
311
|
+
|
|
312
|
+
const symlinkPath = join(vaultRoot, 'escape-symlink.md');
|
|
313
|
+
symlinkSync(outsideFile, symlinkPath);
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
assert.throws(
|
|
317
|
+
() => TEST_MEMORY_STORE.writeNote(projectRoot, 'escape-symlink.md', validNote()),
|
|
318
|
+
/Invalid note path/i,
|
|
319
|
+
'writeNote should refuse to follow a pre-existing symlink',
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// Verify: the outside file is UNTOUCHED
|
|
323
|
+
const content = readFileSync(outsideFile, 'utf8');
|
|
324
|
+
assert.ok(
|
|
325
|
+
!content.includes('memory_id:'),
|
|
326
|
+
'Outside file should NOT be overwritten — symlink guard prevented write through symlink',
|
|
327
|
+
);
|
|
328
|
+
assert.strictEqual(
|
|
329
|
+
content,
|
|
330
|
+
'ORIGINAL CONTENT — should not be overwritten',
|
|
331
|
+
'Outside file content should be exactly what we wrote',
|
|
332
|
+
);
|
|
333
|
+
} finally {
|
|
334
|
+
try { rmSync(outsideFile, { force: true }); } catch { /* ignore */ }
|
|
335
|
+
try { rmSync(symlinkPath, { force: true }); } catch { /* ignore */ }
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('REGRESSION: readNote via symlink returns null (symlink guard blocks info disclosure)', () => {
|
|
340
|
+
// Symlink guard (Fix 2): readNote now refuses to follow a pre-existing
|
|
341
|
+
// symlink, so the outside file's content is NOT returned.
|
|
342
|
+
|
|
343
|
+
const outsideFile = join(tmpdir(), `symlink-read-outside-${uid()}.md`);
|
|
344
|
+
const secretContent = 'SECRET FROM OUTSIDE THE VAULT — information disclosure';
|
|
345
|
+
writeFileSync(outsideFile, secretContent, 'utf8');
|
|
346
|
+
|
|
347
|
+
const symlinkPath = join(vaultRoot, 'disclose-symlink.md');
|
|
348
|
+
symlinkSync(outsideFile, symlinkPath);
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const result = TEST_MEMORY_STORE.readNote(projectRoot, 'disclose-symlink.md');
|
|
352
|
+
assert.strictEqual(
|
|
353
|
+
result,
|
|
354
|
+
null,
|
|
355
|
+
'readNote should return null for a symlink path (symlink guard)',
|
|
356
|
+
);
|
|
357
|
+
} finally {
|
|
358
|
+
try { rmSync(outsideFile, { force: true }); } catch { /* ignore */ }
|
|
359
|
+
try { rmSync(symlinkPath, { force: true }); } catch { /* ignore */ }
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('REGRESSION: filenames containing `..` as a substring (not as a segment) are still accepted', () => {
|
|
364
|
+
// Fix 3: segment-level check rejects `..` SEGMENTS but allows substrings.
|
|
365
|
+
// `my..note.md` is a valid filename.
|
|
366
|
+
|
|
367
|
+
const note = validNote();
|
|
368
|
+
const relPath = 'decisions/my..note.md';
|
|
369
|
+
const result = TEST_MEMORY_STORE.writeNote(projectRoot, relPath, note);
|
|
370
|
+
assert.strictEqual(result.relPath, relPath, 'filename with `..` substring should be accepted');
|
|
371
|
+
|
|
372
|
+
const readBack = TEST_MEMORY_STORE.readNote(projectRoot, relPath);
|
|
373
|
+
assert.ok(readBack, 'readBack should succeed');
|
|
374
|
+
assert.strictEqual(readBack.relPath, relPath, 'readBack relPath should match');
|
|
375
|
+
|
|
376
|
+
// Cleanup
|
|
377
|
+
TEST_MEMORY_STORE.deleteNote(projectRoot, relPath);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('REGRESSION: dangling symlink (target does not exist) is also rejected by symlink guard', () => {
|
|
381
|
+
// The symlink guard must use lstatSync directly — existsSync follows
|
|
382
|
+
// symlinks and returns false for dangling ones, which would have
|
|
383
|
+
// bypassed the original guard. This test ensures the dangling case
|
|
384
|
+
// is covered too.
|
|
385
|
+
|
|
386
|
+
const danglingTarget = join(tmpdir(), `dangling-symlink-target-${uid()}.md`);
|
|
387
|
+
// Do NOT create danglingTarget — it should remain non-existent.
|
|
388
|
+
|
|
389
|
+
const symlinkPath = join(vaultRoot, 'dangling-symlink.md');
|
|
390
|
+
symlinkSync(danglingTarget, symlinkPath);
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
// Sanity: existsSync says false (follows the symlink), lstatSync says
|
|
394
|
+
// true (does not follow).
|
|
395
|
+
assert.strictEqual(
|
|
396
|
+
existsSync(symlinkPath),
|
|
397
|
+
false,
|
|
398
|
+
'precondition: existsSync returns false for dangling symlink',
|
|
399
|
+
);
|
|
400
|
+
assert.strictEqual(
|
|
401
|
+
lstatSync(symlinkPath).isSymbolicLink(),
|
|
402
|
+
true,
|
|
403
|
+
'precondition: lstatSync identifies the symlink correctly',
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
assert.throws(
|
|
407
|
+
() => TEST_MEMORY_STORE.writeNote(projectRoot, 'dangling-symlink.md', validNote()),
|
|
408
|
+
/Invalid note path/i,
|
|
409
|
+
'writeNote should refuse dangling symlinks too (uses lstatSync directly)',
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
assert.strictEqual(
|
|
413
|
+
TEST_MEMORY_STORE.readNote(projectRoot, 'dangling-symlink.md'),
|
|
414
|
+
null,
|
|
415
|
+
'readNote should return null for dangling symlinks',
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
assert.strictEqual(
|
|
419
|
+
TEST_MEMORY_STORE.deleteNote(projectRoot, 'dangling-symlink.md'),
|
|
420
|
+
false,
|
|
421
|
+
'deleteNote should refuse dangling symlinks',
|
|
422
|
+
);
|
|
423
|
+
} finally {
|
|
424
|
+
try { rmSync(symlinkPath, { force: true }); } catch { /* ignore */ }
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/memory-roundtrip.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Rule #42 end-to-end round-trip test: write → read → search → list → delete.
|
|
5
|
+
* Runs THREE times:
|
|
6
|
+
* Mode A: local-only (vault in project temp dir)
|
|
7
|
+
* Mode B: managed (vault in temp dir simulating ~/.local/share/bizar/...)
|
|
8
|
+
* Mode C: namespace variants (project / global / user)
|
|
9
|
+
*
|
|
10
|
+
* Gap closed: memory-system-map §8.1 / §9 Layer-4 "Full write/read/search/delete
|
|
11
|
+
* round-trip — CLI write → CLI status → CLI search → REST read → DELETE".
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { dirname } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const CLI_BIN = join(dirname(__filename), '..', '..', 'cli', 'bin.mjs');
|
|
25
|
+
|
|
26
|
+
const TEST_MEMORY_STORE = await import('../src/server/memory-store.mjs').then((m) => m);
|
|
27
|
+
|
|
28
|
+
/** Unique ID to avoid cross-test pollution */
|
|
29
|
+
const uid = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Full round-trip test helper.
|
|
33
|
+
* @param {'local-only'|'managed'} mode
|
|
34
|
+
* @param {'project'|'global'|'user'} namespace
|
|
35
|
+
*/
|
|
36
|
+
async function runRoundtrip(mode, namespace) {
|
|
37
|
+
const projectRoot = join(tmpdir(), `roundtrip-${mode}-${namespace}-${uid()}`);
|
|
38
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
39
|
+
mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
|
|
40
|
+
|
|
41
|
+
let vaultRoot;
|
|
42
|
+
let sharedRepo;
|
|
43
|
+
|
|
44
|
+
if (mode === 'local-only') {
|
|
45
|
+
vaultRoot = join(projectRoot, '.obsidian');
|
|
46
|
+
mkdirSync(vaultRoot, { recursive: true });
|
|
47
|
+
TEST_MEMORY_STORE.saveConfig(projectRoot, {
|
|
48
|
+
version: 1,
|
|
49
|
+
backend: 'bizar-local',
|
|
50
|
+
projectId: 'roundtrip-test',
|
|
51
|
+
memoryRepo: {
|
|
52
|
+
mode: 'local-only',
|
|
53
|
+
path: vaultRoot,
|
|
54
|
+
remote: null,
|
|
55
|
+
branch: 'main',
|
|
56
|
+
namespace: null,
|
|
57
|
+
},
|
|
58
|
+
namespaces: {
|
|
59
|
+
project: 'projects/roundtrip-test',
|
|
60
|
+
global: 'global/bizar',
|
|
61
|
+
user: 'users/tester',
|
|
62
|
+
},
|
|
63
|
+
lightrag: { enabled: false, host: '127.0.0.1', port: 9621, workingDir: join(projectRoot, '.bizar', 'lightrag') },
|
|
64
|
+
git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false, commitAuthor: 'Bizar Memory <bizar-memory@local>', commitMessageTemplate: 'memory(roundtrip): {summary}' },
|
|
65
|
+
});
|
|
66
|
+
} else {
|
|
67
|
+
// managed mode — vault lives in a shared repo under projects/<id>/
|
|
68
|
+
sharedRepo = join(tmpdir(), `shared-repo-${uid()}`);
|
|
69
|
+
mkdirSync(sharedRepo, { recursive: true });
|
|
70
|
+
// Ensure parent namespace dirs exist
|
|
71
|
+
mkdirSync(join(sharedRepo, 'projects'), { recursive: true });
|
|
72
|
+
mkdirSync(join(sharedRepo, 'global', 'bizar'), { recursive: true });
|
|
73
|
+
mkdirSync(join(sharedRepo, 'users', 'tester'), { recursive: true });
|
|
74
|
+
|
|
75
|
+
const nsMap = {
|
|
76
|
+
project: `projects/roundtrip-test`,
|
|
77
|
+
global: `global/bizar`,
|
|
78
|
+
user: `users/tester`,
|
|
79
|
+
};
|
|
80
|
+
const ns = nsMap[namespace];
|
|
81
|
+
const vaultPath = join(sharedRepo, ns);
|
|
82
|
+
|
|
83
|
+
TEST_MEMORY_STORE.saveConfig(projectRoot, {
|
|
84
|
+
version: 1,
|
|
85
|
+
backend: 'bizar-local',
|
|
86
|
+
projectId: 'roundtrip-test',
|
|
87
|
+
memoryRepo: {
|
|
88
|
+
mode: 'managed',
|
|
89
|
+
path: sharedRepo,
|
|
90
|
+
remote: null,
|
|
91
|
+
branch: 'main',
|
|
92
|
+
namespace: ns,
|
|
93
|
+
},
|
|
94
|
+
namespaces: {
|
|
95
|
+
project: `projects/roundtrip-test`,
|
|
96
|
+
global: 'global/bizar',
|
|
97
|
+
user: 'users/tester',
|
|
98
|
+
},
|
|
99
|
+
lightrag: { enabled: false, host: '127.0.0.1', port: 9621, workingDir: join(projectRoot, '.bizar', 'lightrag') },
|
|
100
|
+
git: { autoPullOnSessionStart: false, autoCommitOnMemoryWrite: false, autoPushOnSessionEnd: false, commitAuthor: 'Bizar Memory <bizar-memory@local>', commitMessageTemplate: 'memory(roundtrip): {summary}' },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
vaultRoot = join(sharedRepo, ns);
|
|
104
|
+
// Ensure the namespace directory exists (writeNote creates it lazily, but
|
|
105
|
+
// we can pre-create to avoid mkdir in the writeNote path)
|
|
106
|
+
mkdirSync(vaultRoot, { recursive: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const noteRelPath = `${namespace}-roundtrip-${uid().slice(0, 8)}.md`;
|
|
110
|
+
const note = {
|
|
111
|
+
frontmatter: {
|
|
112
|
+
memory_id: `roundtrip_${namespace}_${uid().slice(0, 8)}`,
|
|
113
|
+
type: 'session_summary',
|
|
114
|
+
project_id: 'roundtrip-test',
|
|
115
|
+
status: 'active',
|
|
116
|
+
confidence: 'verified',
|
|
117
|
+
created: new Date().toISOString(),
|
|
118
|
+
updated: new Date().toISOString(),
|
|
119
|
+
tags: ['roundtrip', mode, namespace],
|
|
120
|
+
title: `Roundtrip test — ${mode} / ${namespace}`,
|
|
121
|
+
},
|
|
122
|
+
body: `# Roundtrip Test\n\nMode: ${mode}\nNamespace: ${namespace}\nUnique: ${uid()}\n`,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ── Step 1: writeNote ──────────────────────────────────────────────────────
|
|
126
|
+
const written = TEST_MEMORY_STORE.writeNote(projectRoot, noteRelPath, note);
|
|
127
|
+
assert.ok(written, `[${mode}/${namespace}] writeNote should return a result`);
|
|
128
|
+
assert.strictEqual(written.relPath, noteRelPath, `[${mode}/${namespace}] relPath should match`);
|
|
129
|
+
assert.strictEqual(written.schemaValid, true, `[${mode}/${namespace}] schemaValid should be true`);
|
|
130
|
+
|
|
131
|
+
// ── Step 2: readNote ───────────────────────────────────────────────────────
|
|
132
|
+
const readBack = TEST_MEMORY_STORE.readNote(projectRoot, noteRelPath);
|
|
133
|
+
assert.ok(readBack, `[${mode}/${namespace}] readNote should find the note`);
|
|
134
|
+
assert.strictEqual(readBack.relPath, noteRelPath, `[${mode}/${namespace}] readBack relPath should match`);
|
|
135
|
+
assert.strictEqual(readBack.frontmatter.type, 'session_summary', `[${mode}/${namespace}] frontmatter.type should match`);
|
|
136
|
+
assert.ok(readBack.body.includes('Roundtrip Test'), `[${mode}/${namespace}] body should contain expected content`);
|
|
137
|
+
assert.strictEqual(readBack.schemaValid, true, `[${mode}/${namespace}] readBack.schemaValid should be true`);
|
|
138
|
+
|
|
139
|
+
// ── Step 3: searchVault ────────────────────────────────────────────────────
|
|
140
|
+
const searchResults = TEST_MEMORY_STORE.searchVault(projectRoot, 'Roundtrip Test');
|
|
141
|
+
const found = searchResults.find((r) => r.relPath === noteRelPath);
|
|
142
|
+
assert.ok(found, `[${mode}/${namespace}] searchVault should find the note (query: 'Roundtrip Test')`);
|
|
143
|
+
assert.ok(found.snippet.includes('roundtrip test'), `[${mode}/${namespace}] snippet should contain query term`);
|
|
144
|
+
|
|
145
|
+
// ── Step 4: listNotes ──────────────────────────────────────────────────────
|
|
146
|
+
const allNotes = TEST_MEMORY_STORE.listNotes(projectRoot);
|
|
147
|
+
assert.ok(allNotes.length >= 1, `[${mode}/${namespace}] listNotes should return at least 1 note`);
|
|
148
|
+
const listed = allNotes.find((n) => n.relPath === noteRelPath);
|
|
149
|
+
assert.ok(listed, `[${mode}/${namespace}] listNotes should include the written note`);
|
|
150
|
+
assert.strictEqual(listed.frontmatter.type, 'session_summary', `[${mode}/${namespace}] listed frontmatter.type should match`);
|
|
151
|
+
|
|
152
|
+
// ── Step 5: deleteNote ─────────────────────────────────────────────────────
|
|
153
|
+
const deleted = TEST_MEMORY_STORE.deleteNote(projectRoot, noteRelPath);
|
|
154
|
+
assert.strictEqual(deleted, true, `[${mode}/${namespace}] deleteNote should return true`);
|
|
155
|
+
|
|
156
|
+
// ── Step 6: assert it's gone ───────────────────────────────────────────────
|
|
157
|
+
const readAfterDelete = TEST_MEMORY_STORE.readNote(projectRoot, noteRelPath);
|
|
158
|
+
assert.strictEqual(readAfterDelete, null, `[${mode}/${namespace}] readNote should return null after deletion`);
|
|
159
|
+
|
|
160
|
+
// Search should also not find it
|
|
161
|
+
const searchAfterDelete = TEST_MEMORY_STORE.searchVault(projectRoot, 'Roundtrip Test');
|
|
162
|
+
const foundAfterDelete = searchAfterDelete.find((r) => r.relPath === noteRelPath);
|
|
163
|
+
assert.ok(!foundAfterDelete, `[${mode}/${namespace}] searchVault should NOT find deleted note`);
|
|
164
|
+
|
|
165
|
+
// Clean up
|
|
166
|
+
try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
167
|
+
if (sharedRepo) {
|
|
168
|
+
try { rmSync(sharedRepo, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Test suites per mode ───────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
describe('memory-roundtrip: local-only mode', () => {
|
|
177
|
+
it('write → read → search → list → delete', async () => {
|
|
178
|
+
await runRoundtrip('local-only', 'project');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('memory-roundtrip: managed mode / project namespace', () => {
|
|
183
|
+
it('write → read → search → list → delete', async () => {
|
|
184
|
+
await runRoundtrip('managed', 'project');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('memory-roundtrip: managed mode / global namespace', () => {
|
|
189
|
+
it('write → read → search → list → delete', async () => {
|
|
190
|
+
await runRoundtrip('managed', 'global');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('memory-roundtrip: managed mode / user namespace', () => {
|
|
195
|
+
it('write → read → search → list → delete', async () => {
|
|
196
|
+
await runRoundtrip('managed', 'user');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── Three-run variant for rule #42 documentation ─────────────────────────────
|
|
201
|
+
// This test documents that the round-trip runs three times as required.
|
|
202
|
+
|
|
203
|
+
describe('memory-roundtrip: rule #42 three-run variant', () => {
|
|
204
|
+
// This test is a meta-test: it runs the roundtrip three times in sequence
|
|
205
|
+
// to satisfy AGENTS_SELF_IMPROVEMENT rule #42 "run it THREE times" requirement.
|
|
206
|
+
// Each run uses a different mode.
|
|
207
|
+
|
|
208
|
+
it('run 1 of 3: local-only mode roundtrip', async () => {
|
|
209
|
+
await runRoundtrip('local-only', 'project');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('run 2 of 3: managed project namespace roundtrip', async () => {
|
|
213
|
+
await runRoundtrip('managed', 'project');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('run 3 of 3: managed global namespace roundtrip', async () => {
|
|
217
|
+
await runRoundtrip('managed', 'global');
|
|
218
|
+
});
|
|
219
|
+
});
|