vault.test.mjs
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | /** |
| 2 | * Vault tests: listMarkdownFiles, readNote, parseFrontmatterAndBody, resolveVaultRelativePath, noteFileExistsInVault, normalizeSlug, normalizeTags. |
| 3 | */ |
| 4 | import { describe, it } from 'node:test'; |
| 5 | import assert from 'node:assert'; |
| 6 | import fs from 'fs'; |
| 7 | import os from 'os'; |
| 8 | import path from 'path'; |
| 9 | import { fileURLToPath } from 'url'; |
| 10 | import { |
| 11 | listMarkdownFiles, |
| 12 | readNote, |
| 13 | parseFrontmatterAndBody, |
| 14 | resolveVaultRelativePath, |
| 15 | normalizeSlug, |
| 16 | normalizeTags, |
| 17 | noteFileExistsInVault, |
| 18 | listVaultFolderOptions, |
| 19 | effectiveProjectSlug, |
| 20 | } from '../lib/vault.mjs'; |
| 21 | |
| 22 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 23 | const vaultPath = path.join(__dirname, 'fixtures', 'vault-fs'); |
| 24 | |
| 25 | describe('vault', () => { |
| 26 | describe('listMarkdownFiles', () => { |
| 27 | it('returns vault-relative paths for all .md files', () => { |
| 28 | const paths = listMarkdownFiles(vaultPath); |
| 29 | assert(Array.isArray(paths)); |
| 30 | assert(paths.length >= 3); |
| 31 | assert(paths.some((p) => p === 'inbox/one.md')); |
| 32 | assert(paths.some((p) => p === 'inbox/two.md')); |
| 33 | assert(paths.some((p) => p === 'projects/foo/note.md')); |
| 34 | paths.forEach((p) => assert(!p.includes('\\'))); |
| 35 | }); |
| 36 | |
| 37 | it('respects ignore option', () => { |
| 38 | const paths = listMarkdownFiles(vaultPath, { ignore: ['inbox'] }); |
| 39 | assert(!paths.some((p) => p.startsWith('inbox/'))); |
| 40 | assert(paths.some((p) => p === 'projects/foo/note.md')); |
| 41 | }); |
| 42 | }); |
| 43 | |
| 44 | describe('readNote', () => { |
| 45 | it('returns parsed note with path, project, tags, date', () => { |
| 46 | const note = readNote(vaultPath, 'inbox/one.md'); |
| 47 | assert.strictEqual(note.path, 'inbox/one.md'); |
| 48 | assert.strictEqual(note.project, 'foo'); |
| 49 | assert.deepStrictEqual(note.tags, ['a', 'b']); |
| 50 | assert(note.date && note.date.startsWith('2025')); |
| 51 | assert(note.body && note.body.includes('Inbox one')); |
| 52 | }); |
| 53 | |
| 54 | it('throws for path that escapes vault', () => { |
| 55 | assert.throws( |
| 56 | () => readNote(vaultPath, '../../../etc/passwd'), |
| 57 | /Invalid path|escapes vault/ |
| 58 | ); |
| 59 | }); |
| 60 | |
| 61 | it('throws for non-existent file', () => { |
| 62 | assert.throws( |
| 63 | () => readNote(vaultPath, 'inbox/nonexistent.md'), |
| 64 | /Note not found/ |
| 65 | ); |
| 66 | }); |
| 67 | |
| 68 | it('security: rejects symlinks that resolve outside the vault', () => { |
| 69 | const root = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-vault-symlink-root-')); |
| 70 | const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-vault-symlink-outside-')); |
| 71 | try { |
| 72 | fs.mkdirSync(path.join(root, 'inbox'), { recursive: true }); |
| 73 | fs.writeFileSync(path.join(outside, 'secret.md'), '# Secret\n\noutside vault'); |
| 74 | fs.symlinkSync(path.join(outside, 'secret.md'), path.join(root, 'inbox', 'linked.md')); |
| 75 | |
| 76 | assert.throws( |
| 77 | () => readNote(root, 'inbox/linked.md'), |
| 78 | /Invalid path|escapes vault|Note not found/ |
| 79 | ); |
| 80 | assert.strictEqual(noteFileExistsInVault(root, 'inbox/linked.md'), false); |
| 81 | } finally { |
| 82 | fs.rmSync(root, { recursive: true, force: true }); |
| 83 | fs.rmSync(outside, { recursive: true, force: true }); |
| 84 | } |
| 85 | }); |
| 86 | }); |
| 87 | |
| 88 | describe('parseFrontmatterAndBody', () => { |
| 89 | it('parses YAML frontmatter and body', () => { |
| 90 | const content = '---\ntitle: Hi\ndate: 2025-01-01\n---\n\n# Hello'; |
| 91 | const { frontmatter, body } = parseFrontmatterAndBody(content); |
| 92 | assert.strictEqual(frontmatter.title, 'Hi'); |
| 93 | const dateStr = frontmatter.date instanceof Date ? frontmatter.date.toISOString().slice(0, 10) : String(frontmatter.date); |
| 94 | assert.strictEqual(dateStr.slice(0, 10), '2025-01-01'); |
| 95 | assert.strictEqual(body.trim(), '# Hello'); |
| 96 | }); |
| 97 | |
| 98 | it('returns empty frontmatter when no fence', () => { |
| 99 | const { frontmatter, body } = parseFrontmatterAndBody('# No frontmatter'); |
| 100 | assert.deepStrictEqual(frontmatter, {}); |
| 101 | assert.strictEqual(body.trim(), '# No frontmatter'); |
| 102 | }); |
| 103 | }); |
| 104 | |
| 105 | describe('resolveVaultRelativePath', () => { |
| 106 | it('normalizes and returns vault-relative path', () => { |
| 107 | const out = resolveVaultRelativePath(vaultPath, 'inbox/foo.md'); |
| 108 | assert.strictEqual(out, 'inbox/foo.md'); |
| 109 | }); |
| 110 | |
| 111 | it('rejects path that escapes vault', () => { |
| 112 | assert.throws( |
| 113 | () => resolveVaultRelativePath(vaultPath, '../other/foo.md'), |
| 114 | /Invalid path|escapes vault/ |
| 115 | ); |
| 116 | }); |
| 117 | |
| 118 | it('rejects absolute path', () => { |
| 119 | assert.throws( |
| 120 | () => resolveVaultRelativePath(vaultPath, '/tmp/foo.md'), |
| 121 | /Invalid path/ |
| 122 | ); |
| 123 | }); |
| 124 | }); |
| 125 | |
| 126 | describe('noteFileExistsInVault', () => { |
| 127 | it('returns true for an existing note path', () => { |
| 128 | assert.strictEqual(noteFileExistsInVault(vaultPath, 'inbox/one.md'), true); |
| 129 | }); |
| 130 | |
| 131 | it('returns false for missing file', () => { |
| 132 | assert.strictEqual(noteFileExistsInVault(vaultPath, 'inbox/ghost.md'), false); |
| 133 | }); |
| 134 | |
| 135 | it('returns false for empty or invalid input', () => { |
| 136 | assert.strictEqual(noteFileExistsInVault(vaultPath, ''), false); |
| 137 | assert.strictEqual(noteFileExistsInVault(vaultPath, ' '), false); |
| 138 | assert.strictEqual(noteFileExistsInVault(vaultPath, null), false); |
| 139 | }); |
| 140 | |
| 141 | it('returns false for escape paths without throwing', () => { |
| 142 | assert.strictEqual(noteFileExistsInVault(vaultPath, '../../../etc/passwd'), false); |
| 143 | }); |
| 144 | }); |
| 145 | |
| 146 | describe('listVaultFolderOptions', () => { |
| 147 | it('returns inbox first then top-level and projects/* subdirs sorted', () => { |
| 148 | const root = fs.mkdtempSync(path.join(__dirname, 'fixtures', 'tmp-vault-folders-')); |
| 149 | try { |
| 150 | fs.mkdirSync(path.join(root, 'inbox')); |
| 151 | fs.mkdirSync(path.join(root, 'media')); |
| 152 | fs.mkdirSync(path.join(root, 'projects', 'born-free'), { recursive: true }); |
| 153 | const o = listVaultFolderOptions(root); |
| 154 | assert.deepStrictEqual(o, ['inbox', 'media', 'projects', 'projects/born-free']); |
| 155 | } finally { |
| 156 | try { |
| 157 | fs.rmSync(root, { recursive: true }); |
| 158 | } catch (_) {} |
| 159 | } |
| 160 | }); |
| 161 | |
| 162 | it('returns at least inbox for missing path', () => { |
| 163 | assert.deepStrictEqual(listVaultFolderOptions('/no/such/vault/path'), ['inbox']); |
| 164 | }); |
| 165 | }); |
| 166 | |
| 167 | describe('normalizeSlug', () => { |
| 168 | it('lowercases and keeps only a-z0-9 and hyphen', () => { |
| 169 | assert.strictEqual(normalizeSlug('Foo Bar'), 'foo-bar'); |
| 170 | assert.strictEqual(normalizeSlug(' xYz '), 'xyz'); |
| 171 | }); |
| 172 | }); |
| 173 | |
| 174 | describe('normalizeTags', () => { |
| 175 | it('accepts array and returns normalized array', () => { |
| 176 | assert.deepStrictEqual(normalizeTags(['A', 'b']), ['a', 'b']); |
| 177 | }); |
| 178 | it('accepts comma-sep string', () => { |
| 179 | assert.deepStrictEqual(normalizeTags('x, Y'), ['x', 'y']); |
| 180 | }); |
| 181 | }); |
| 182 | |
| 183 | describe('effectiveProjectSlug', () => { |
| 184 | it('uses normalized frontmatter project when set', () => { |
| 185 | assert.strictEqual(effectiveProjectSlug('inbox/x.md', { project: 'My Acme' }), 'my-acme'); |
| 186 | }); |
| 187 | it('infers from projects/<slug>/ when frontmatter project absent', () => { |
| 188 | assert.strictEqual(effectiveProjectSlug('projects/born-free/inbox/n.md', {}), 'born-free'); |
| 189 | }); |
| 190 | it('returns undefined when no fm project and path not under projects/', () => { |
| 191 | assert.strictEqual(effectiveProjectSlug('inbox/x.md', {}), undefined); |
| 192 | }); |
| 193 | }); |
| 194 | }); |