@bitbonsai/mcpvault 0.9.0 → 0.10.0

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.
@@ -1,118 +0,0 @@
1
- import { test, expect, describe } from "vitest";
2
- import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
3
- const handler = new FrontmatterHandler();
4
- test("parse note with frontmatter", () => {
5
- const content = `---
6
- title: Test Note
7
- tags: [test, example]
8
- created: 2023-01-01
9
- ---
10
-
11
- # Test Note
12
-
13
- This is a test note with frontmatter.`;
14
- const result = handler.parse(content);
15
- expect(result.frontmatter.title).toBe("Test Note");
16
- expect(result.frontmatter.tags).toEqual(["test", "example"]);
17
- expect(result.frontmatter.created).toEqual(new Date("2023-01-01"));
18
- expect(result.content.trim()).toBe("# Test Note\n\nThis is a test note with frontmatter.");
19
- });
20
- test("parse note without frontmatter", () => {
21
- const content = `# Test Note
22
-
23
- This is a test note without frontmatter.`;
24
- const result = handler.parse(content);
25
- expect(result.frontmatter).toEqual({});
26
- expect(result.content).toBe(content);
27
- });
28
- test("stringify with frontmatter", () => {
29
- const frontmatter = {
30
- title: "Test Note",
31
- tags: ["test", "example"]
32
- };
33
- const content = "# Test Note\n\nContent here.";
34
- const result = handler.stringify(frontmatter, content);
35
- expect(result).toContain("---");
36
- expect(result).toContain("title: Test Note");
37
- expect(result).toContain("tags:");
38
- expect(result).toContain("# Test Note");
39
- });
40
- test("stringify without frontmatter", () => {
41
- const content = "# Test Note\n\nContent here.";
42
- const result = handler.stringify({}, content);
43
- expect(result).toBe(content);
44
- });
45
- test("validate valid frontmatter", () => {
46
- const frontmatter = {
47
- title: "Valid Title",
48
- tags: ["tag1", "tag2"],
49
- date: new Date("2023-01-01"),
50
- count: 42,
51
- enabled: true
52
- };
53
- const result = handler.validate(frontmatter);
54
- expect(result.isValid).toBe(true);
55
- expect(result.errors).toHaveLength(0);
56
- });
57
- test("validate invalid frontmatter with function", () => {
58
- const frontmatter = {
59
- title: "Invalid",
60
- badFunction: () => "not allowed"
61
- };
62
- const result = handler.validate(frontmatter);
63
- expect(result.isValid).toBe(false);
64
- expect(result.errors.length).toBeGreaterThan(0);
65
- // The specific error message may vary between YAML libraries
66
- expect(result.errors[0]).toMatch(/Functions are not allowed|Invalid YAML structure/);
67
- });
68
- test("update frontmatter in existing content", () => {
69
- const content = `---
70
- title: Old Title
71
- tags: [old]
72
- ---
73
-
74
- # Content
75
-
76
- Some content here.`;
77
- const updates = {
78
- title: "New Title",
79
- modified: "2023-12-01"
80
- };
81
- const result = handler.updateFrontmatter(content, updates);
82
- expect(result).toContain("title: New Title");
83
- expect(result).toContain("modified: '2023-12-01'");
84
- expect(result).toContain("tags:");
85
- expect(result).toContain("# Content");
86
- });
87
- describe("parseFrontmatter", () => {
88
- test("returns undefined for null and undefined", () => {
89
- expect(parseFrontmatter(null)).toBeUndefined();
90
- expect(parseFrontmatter(undefined)).toBeUndefined();
91
- });
92
- test("passes through a plain object", () => {
93
- const obj = { tags: ["test"], title: "Hello" };
94
- expect(parseFrontmatter(obj)).toBe(obj);
95
- });
96
- test("parses a JSON string into an object", () => {
97
- const input = '{"tags": ["test"], "title": "Hello"}';
98
- expect(parseFrontmatter(input)).toEqual({ tags: ["test"], title: "Hello" });
99
- });
100
- test("parses an empty JSON object string", () => {
101
- expect(parseFrontmatter("{}")).toEqual({});
102
- });
103
- test("throws for a non-JSON string", () => {
104
- expect(() => parseFrontmatter("not json")).toThrow("frontmatter must be a JSON object");
105
- });
106
- test("throws for a JSON array string", () => {
107
- expect(() => parseFrontmatter('[1, 2, 3]')).toThrow("frontmatter must be a JSON object");
108
- });
109
- test("throws for a JSON primitive string", () => {
110
- expect(() => parseFrontmatter('"just a string"')).toThrow("frontmatter must be a JSON object");
111
- });
112
- test("throws for an array value", () => {
113
- expect(() => parseFrontmatter([1, 2, 3])).toThrow("frontmatter must be a JSON object");
114
- });
115
- test("throws for a number value", () => {
116
- expect(() => parseFrontmatter(42)).toThrow("frontmatter must be a JSON object");
117
- });
118
- });
@@ -1,168 +0,0 @@
1
- import { test, expect, beforeEach, afterEach, describe } from "vitest";
2
- import { FileSystemService } from "./filesystem.js";
3
- import { FrontmatterHandler } from "./frontmatter.js";
4
- import { PathFilter } from "./pathfilter.js";
5
- import { SearchService } from "./search.js";
6
- import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
7
- import { join } from "path";
8
- import { tmpdir } from "os";
9
- let testVaultPath;
10
- let pathFilter;
11
- let frontmatterHandler;
12
- let fileSystem;
13
- let searchService;
14
- beforeEach(async () => {
15
- testVaultPath = await mkdtemp(join(tmpdir(), "mcpvault-integration-"));
16
- // Initialize services (same as server.ts)
17
- pathFilter = new PathFilter();
18
- frontmatterHandler = new FrontmatterHandler();
19
- fileSystem = new FileSystemService(testVaultPath, pathFilter, frontmatterHandler);
20
- searchService = new SearchService(testVaultPath, pathFilter);
21
- });
22
- afterEach(async () => {
23
- try {
24
- await rm(testVaultPath, { recursive: true });
25
- }
26
- catch {
27
- // Ignore cleanup errors
28
- }
29
- });
30
- // ============================================================================
31
- // INTEGRATION TESTS - END-TO-END WORKFLOW
32
- // ============================================================================
33
- describe("Integration: Service Layer Workflows", () => {
34
- test("write, read, and delete note workflow", async () => {
35
- // 1. Write a note with frontmatter
36
- await fileSystem.writeNote({
37
- path: "test-note.md",
38
- content: "# Test Note\n\nThis is a test.",
39
- frontmatter: { tags: ["test"], status: "draft" }
40
- });
41
- // 2. Read the note back
42
- const note = await fileSystem.readNote("test-note.md");
43
- expect(note.content).toContain("This is a test");
44
- expect(note.frontmatter?.tags).toEqual(["test"]);
45
- expect(note.frontmatter?.status).toBe("draft");
46
- // 3. Delete the note
47
- const deleteResult = await fileSystem.deleteNote({
48
- path: "test-note.md",
49
- confirmPath: "test-note.md"
50
- });
51
- expect(deleteResult.success).toBe(true);
52
- });
53
- test("search notes with special characters in filenames", async () => {
54
- // Create notes with special characters in paths
55
- const testCases = [
56
- { path: "folder (archive)/note [old].md", content: "# Old Note\n\nArchived keyword." },
57
- { path: "C++/notes.md", content: "# C++ Notes\n\nProgramming keyword." },
58
- { path: "backup.2024/important.md", content: "# Important\n\nBackup keyword." },
59
- { path: "price$100.md", content: "# Pricing\n\nCost keyword." }
60
- ];
61
- // Write all test notes
62
- for (const { path, content } of testCases) {
63
- if (path.includes('/')) {
64
- const dirName = path.split('/')[0];
65
- if (dirName) {
66
- await mkdir(join(testVaultPath, dirName), { recursive: true });
67
- }
68
- }
69
- await writeFile(join(testVaultPath, path), content);
70
- }
71
- // Search for keyword
72
- const results = await searchService.search({
73
- query: "keyword",
74
- limit: 10
75
- });
76
- expect(results.length).toBe(4);
77
- // Verify paths with special characters are returned correctly
78
- const paths = results.map((r) => r.p);
79
- expect(paths).toContain("folder (archive)/note [old].md");
80
- expect(paths).toContain("C++/notes.md");
81
- });
82
- test("write note with regex special chars in content", async () => {
83
- const content = `# Price List
84
-
85
- Item: Widget ($10.50)
86
- Regex: [a-z]+ matches lowercase
87
- Math: 2 + 2 = 4
88
- Pattern: backup.2024/**/*.md`;
89
- await fileSystem.writeNote({
90
- path: "special-chars.md",
91
- content
92
- });
93
- // Read back and verify exact content
94
- const note = await fileSystem.readNote("special-chars.md");
95
- expect(note.content).toContain("($10.50)");
96
- expect(note.content).toContain("[a-z]+");
97
- expect(note.content).toContain("2 + 2 = 4");
98
- expect(note.content).toContain("backup.2024/**/*.md");
99
- });
100
- test("search matches note filename even without content match", async () => {
101
- // Issue #30: notes without a heading that rely on filename for discovery
102
- await fileSystem.writeNote({
103
- path: "Yard.md",
104
- content: "Some info about lawn care and gardening tips."
105
- });
106
- await fileSystem.writeNote({
107
- path: "Kitchen.md",
108
- content: "Recipes and kitchen organization."
109
- });
110
- // Search for "yard" — should match Yard.md by filename
111
- const results = await searchService.search({
112
- query: "yard",
113
- searchContent: true,
114
- limit: 10
115
- });
116
- expect(results.length).toBeGreaterThanOrEqual(1);
117
- expect(results.some(r => r.p === "Yard.md")).toBe(true);
118
- // Verify filename-only match has reasonable fields
119
- const yardResult = results.find(r => r.p === "Yard.md");
120
- expect(yardResult.t).toBe("Yard");
121
- expect(yardResult.mc).toBeGreaterThanOrEqual(1);
122
- // Search for "kitchen" — should match Kitchen.md by filename
123
- const kitchenResults = await searchService.search({
124
- query: "kitchen",
125
- searchContent: true,
126
- limit: 10
127
- });
128
- // Should match both filename AND content (content contains "kitchen")
129
- expect(kitchenResults.some(r => r.p === "Kitchen.md")).toBe(true);
130
- });
131
- test("multi-step workflow: search, read multiple, update frontmatter", async () => {
132
- // Create several notes
133
- for (let i = 1; i <= 3; i++) {
134
- await fileSystem.writeNote({
135
- path: `note-${i}.md`,
136
- content: `# Note ${i}\n\nThis contains searchterm.`,
137
- frontmatter: { id: i, processed: false }
138
- });
139
- }
140
- // Search for notes
141
- const searchResults = await searchService.search({
142
- query: "searchterm",
143
- limit: 10
144
- });
145
- expect(searchResults.length).toBe(3);
146
- // Read multiple notes
147
- const paths = searchResults.map(r => r.p);
148
- const readResult = await fileSystem.readMultipleNotes({
149
- paths,
150
- includeContent: true,
151
- includeFrontmatter: true
152
- });
153
- expect(readResult.successful.length).toBe(3);
154
- // Update frontmatter on all notes
155
- for (const path of paths) {
156
- await fileSystem.updateFrontmatter({
157
- path,
158
- frontmatter: { processed: true },
159
- merge: true
160
- });
161
- }
162
- // Verify updates
163
- for (const path of paths) {
164
- const note = await fileSystem.readNote(path);
165
- expect(note.frontmatter?.processed).toBe(true);
166
- }
167
- });
168
- });
@@ -1,264 +0,0 @@
1
- import { test, expect, describe } from "vitest";
2
- import { PathFilter } from "./pathfilter.js";
3
- describe("PathFilter", () => {
4
- // ============================================================================
5
- // BASIC FUNCTIONALITY
6
- // ============================================================================
7
- test("allows markdown files by default", () => {
8
- const filter = new PathFilter();
9
- expect(filter.isAllowed("notes/test.md")).toBe(true);
10
- expect(filter.isAllowed("test.markdown")).toBe(true);
11
- expect(filter.isAllowed("folder/subfolder/note.txt")).toBe(true);
12
- });
13
- test("blocks .obsidian directory", () => {
14
- const filter = new PathFilter();
15
- expect(filter.isAllowed(".obsidian")).toBe(false);
16
- expect(filter.isAllowed(".obsidian/app.json")).toBe(false);
17
- expect(filter.isAllowed(".obsidian/plugins/plugin/main.js")).toBe(false);
18
- });
19
- test("blocks .git directory", () => {
20
- const filter = new PathFilter();
21
- expect(filter.isAllowed(".git")).toBe(false);
22
- expect(filter.isAllowed(".git/config")).toBe(false);
23
- expect(filter.isAllowed(".git/objects/abc123")).toBe(false);
24
- });
25
- test("blocks node_modules", () => {
26
- const filter = new PathFilter();
27
- expect(filter.isAllowed("node_modules")).toBe(false);
28
- expect(filter.isAllowed("node_modules/package/index.js")).toBe(false);
29
- });
30
- test("blocks system files", () => {
31
- const filter = new PathFilter();
32
- expect(filter.isAllowed(".DS_Store")).toBe(false);
33
- expect(filter.isAllowed("Thumbs.db")).toBe(false);
34
- });
35
- test("blocks non-allowed extensions", () => {
36
- const filter = new PathFilter();
37
- expect(filter.isAllowed("script.js")).toBe(false);
38
- expect(filter.isAllowed("data.json")).toBe(false);
39
- expect(filter.isAllowed("image.png")).toBe(false);
40
- });
41
- test("allows non-note files for directory listing", () => {
42
- const filter = new PathFilter();
43
- expect(filter.isAllowedForListing("image.png")).toBe(true);
44
- expect(filter.isAllowedForListing("docs/report.pdf")).toBe(true);
45
- expect(filter.isAllowedForListing("archive/data.json")).toBe(true);
46
- });
47
- test("blocks restricted paths in directory listing", () => {
48
- const filter = new PathFilter();
49
- expect(filter.isAllowedForListing(".obsidian/app.json")).toBe(false);
50
- expect(filter.isAllowedForListing(".git/config")).toBe(false);
51
- expect(filter.isAllowedForListing("node_modules/pkg/index.js")).toBe(false);
52
- expect(filter.isAllowedForListing(".DS_Store")).toBe(false);
53
- });
54
- // ============================================================================
55
- // REGEX SPECIAL CHARACTERS - SECURITY TESTS
56
- // ============================================================================
57
- describe("regex special characters in paths", () => {
58
- test("handles dots in filenames literally", () => {
59
- const filter = new PathFilter();
60
- // Dots should be literal, not regex wildcards
61
- expect(filter.isAllowed("file.name.md")).toBe(true);
62
- expect(filter.isAllowed("v1.0.0-notes.md")).toBe(true);
63
- });
64
- test("handles parentheses in paths", () => {
65
- const filter = new PathFilter();
66
- expect(filter.isAllowed("notes/(archived)/old.md")).toBe(true);
67
- expect(filter.isAllowed("project (copy).md")).toBe(true);
68
- });
69
- test("handles square brackets in paths", () => {
70
- const filter = new PathFilter();
71
- expect(filter.isAllowed("notes/[2024]/january.md")).toBe(true);
72
- expect(filter.isAllowed("[inbox]/task.md")).toBe(true);
73
- });
74
- test("handles curly braces in paths", () => {
75
- const filter = new PathFilter();
76
- expect(filter.isAllowed("templates/{daily}.md")).toBe(true);
77
- });
78
- test("handles plus signs in paths", () => {
79
- const filter = new PathFilter();
80
- expect(filter.isAllowed("C++/notes.md")).toBe(true);
81
- expect(filter.isAllowed("topic+subtopic.md")).toBe(true);
82
- });
83
- test("handles question marks in paths", () => {
84
- const filter = new PathFilter();
85
- // Question mark is a glob wildcard, but in actual filenames should work
86
- expect(filter.isAllowed("FAQ?.md")).toBe(true);
87
- });
88
- test("handles asterisks in filenames", () => {
89
- const filter = new PathFilter();
90
- // Asterisk in filename (rare but valid on Unix)
91
- expect(filter.isAllowed("important*.md")).toBe(true);
92
- expect(filter.isAllowed("file*name.md")).toBe(true);
93
- expect(filter.isAllowed("notes/todo*.md")).toBe(true);
94
- });
95
- test("asterisk in custom ignored pattern works as glob", () => {
96
- const filter = new PathFilter({
97
- ignoredPatterns: ["temp*/**"]
98
- });
99
- // Pattern uses * as wildcard - should match temp, temp1, temporary, etc.
100
- expect(filter.isAllowed("temp/file.md")).toBe(false);
101
- expect(filter.isAllowed("temp1/file.md")).toBe(false);
102
- expect(filter.isAllowed("temporary/file.md")).toBe(false);
103
- // Should NOT match "atemp" (pattern starts with temp)
104
- expect(filter.isAllowed("atemp/file.md")).toBe(true);
105
- });
106
- test("double asterisk ** matches nested paths", () => {
107
- const filter = new PathFilter({
108
- ignoredPatterns: ["archive/**"]
109
- });
110
- expect(filter.isAllowed("archive/old.md")).toBe(false);
111
- expect(filter.isAllowed("archive/2024/jan/note.md")).toBe(false);
112
- expect(filter.isAllowed("other/archive/note.md")).toBe(true);
113
- });
114
- test("handles pipe character in paths", () => {
115
- const filter = new PathFilter();
116
- expect(filter.isAllowed("option|choice.md")).toBe(true);
117
- });
118
- test("handles caret in paths", () => {
119
- const filter = new PathFilter();
120
- expect(filter.isAllowed("version^2.md")).toBe(true);
121
- });
122
- test("handles dollar sign in paths", () => {
123
- const filter = new PathFilter();
124
- expect(filter.isAllowed("price$100.md")).toBe(true);
125
- expect(filter.isAllowed("$HOME/notes.md")).toBe(true);
126
- });
127
- test("handles backslash (Windows paths)", () => {
128
- const filter = new PathFilter();
129
- // Backslashes should be normalized to forward slashes
130
- expect(filter.isAllowed("folder\\subfolder\\note.md")).toBe(true);
131
- });
132
- });
133
- // ============================================================================
134
- // CUSTOM IGNORED PATTERNS WITH SPECIAL CHARS
135
- // ============================================================================
136
- describe("custom patterns with special characters", () => {
137
- test("custom pattern with dots is treated literally", () => {
138
- const filter = new PathFilter({
139
- ignoredPatterns: ["backup.2024/**"]
140
- });
141
- expect(filter.isAllowed("backup.2024/notes.md")).toBe(false);
142
- // "backup_2024" should NOT match "backup.2024" pattern
143
- expect(filter.isAllowed("backup_2024/notes.md")).toBe(true);
144
- });
145
- test("custom pattern with parentheses works", () => {
146
- const filter = new PathFilter({
147
- ignoredPatterns: ["(archive)/**"]
148
- });
149
- expect(filter.isAllowed("(archive)/old.md")).toBe(false);
150
- expect(filter.isAllowed("archive/old.md")).toBe(true);
151
- });
152
- test("custom pattern with brackets works", () => {
153
- const filter = new PathFilter({
154
- ignoredPatterns: ["[trash]/**"]
155
- });
156
- expect(filter.isAllowed("[trash]/deleted.md")).toBe(false);
157
- expect(filter.isAllowed("trash/deleted.md")).toBe(true);
158
- });
159
- });
160
- // ============================================================================
161
- // PATH TRAVERSAL ATTEMPTS
162
- // ============================================================================
163
- describe("path traversal prevention", () => {
164
- test("blocks obvious traversal patterns", () => {
165
- const filter = new PathFilter({
166
- ignoredPatterns: ["../**"]
167
- });
168
- expect(filter.isAllowed("../secret.md")).toBe(false);
169
- expect(filter.isAllowed("../../etc/passwd")).toBe(false);
170
- });
171
- test("handles encoded traversal attempts", () => {
172
- const filter = new PathFilter();
173
- // These should be allowed by PathFilter (path validation is in FileSystem)
174
- // but filter shouldn't crash on unusual characters
175
- expect(() => filter.isAllowed("%2e%2e/secret.md")).not.toThrow();
176
- expect(() => filter.isAllowed("..%2fnotes.md")).not.toThrow();
177
- });
178
- });
179
- test("allows Obsidian first-party file types", () => {
180
- const filter = new PathFilter();
181
- expect(filter.isAllowed("_Bases/daily-notes.base")).toBe(true);
182
- expect(filter.isAllowed("canvas/mindmap.canvas")).toBe(true);
183
- });
184
- // ============================================================================
185
- // FILTER PATHS BATCH OPERATION
186
- // ============================================================================
187
- describe("filterPaths", () => {
188
- test("filters array of paths correctly", () => {
189
- const filter = new PathFilter();
190
- const paths = [
191
- "notes/valid.md",
192
- ".obsidian/config.json",
193
- "archive/old.md",
194
- ".git/HEAD",
195
- "readme.txt"
196
- ];
197
- const allowed = filter.filterPaths(paths);
198
- expect(allowed).toEqual([
199
- "notes/valid.md",
200
- "archive/old.md",
201
- "readme.txt"
202
- ]);
203
- });
204
- test("handles empty array", () => {
205
- const filter = new PathFilter();
206
- expect(filter.filterPaths([])).toEqual([]);
207
- });
208
- test("handles array with all blocked paths", () => {
209
- const filter = new PathFilter();
210
- const paths = [
211
- ".obsidian/app.json",
212
- ".git/config",
213
- "node_modules/pkg/index.js"
214
- ];
215
- expect(filter.filterPaths(paths)).toEqual([]);
216
- });
217
- });
218
- // ============================================================================
219
- // EDGE CASES
220
- // ============================================================================
221
- describe("edge cases", () => {
222
- test("handles empty path", () => {
223
- const filter = new PathFilter();
224
- expect(() => filter.isAllowed("")).not.toThrow();
225
- });
226
- test("handles path with only extension", () => {
227
- const filter = new PathFilter();
228
- expect(filter.isAllowed(".md")).toBe(true);
229
- });
230
- test("handles very long paths", () => {
231
- const filter = new PathFilter();
232
- const longPath = "a/".repeat(100) + "note.md";
233
- expect(() => filter.isAllowed(longPath)).not.toThrow();
234
- expect(filter.isAllowed(longPath)).toBe(true);
235
- });
236
- test("handles unicode characters in paths", () => {
237
- const filter = new PathFilter();
238
- expect(filter.isAllowed("notes/日本語.md")).toBe(true);
239
- expect(filter.isAllowed("émojis/🎉.md")).toBe(true);
240
- expect(filter.isAllowed("中文/笔记.md")).toBe(true);
241
- });
242
- test("handles spaces in paths", () => {
243
- const filter = new PathFilter();
244
- expect(filter.isAllowed("my notes/important file.md")).toBe(true);
245
- });
246
- test("handles directories (no extension)", () => {
247
- const filter = new PathFilter();
248
- // Directories should be allowed (no extension check)
249
- expect(filter.isAllowed("folder/subfolder/")).toBe(true);
250
- expect(filter.isAllowed("notes")).toBe(true);
251
- });
252
- test("handles directories with dots in their names", () => {
253
- const filter = new PathFilter();
254
- // Folders with dots should be allowed (common pattern: "1. Project", "2.5 Notes")
255
- expect(filter.isAllowed("1. Project")).toBe(true);
256
- expect(filter.isAllowed("2. Archive")).toBe(true);
257
- expect(filter.isAllowed("3.5 Research")).toBe(true);
258
- expect(filter.isAllowed("1. Project/subfolder")).toBe(true);
259
- expect(filter.isAllowed("1. Project/note.md")).toBe(true);
260
- // But files in those folders should still need proper extensions
261
- expect(filter.isAllowed("1. Project/file.js")).toBe(false);
262
- });
263
- });
264
- });