14 Commits
1.1.2 ... 1.1.3

21 changed files with 1059 additions and 249 deletions

View File

@@ -58,7 +58,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '20'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies

View File

@@ -10,6 +10,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
--- ---
## [1.1.3] - 2025-12-16
### Fixed
- **Sentence Case**: Fixed remaining UI text violations
- `main.ts`: "Toggle MCP Server" → "Toggle MCP server"
- `settings.ts`: "Authentication & Configuration" → "Authentication & configuration"
- `settings.ts`: "UI Notifications" → "UI notifications"
- **Promise Handling**: Improved async/promise patterns per Obsidian guidelines
- `main.ts`: Changed `async onunload()` to synchronous with `void this.stopServer()`
- `routes.ts`: Wrapped async Express handler with void IIFE pattern
- `mcp-server.ts`: Promise rejection now always uses Error instance
- **Async/Await Cleanup**: Removed `async` from 7 methods that contained no `await`:
- `mcp-server.ts`: `handleInitialize`, `handleListTools`
- `vault-tools.ts`: `getVaultInfo`, `listNotes`, `createFileMetadataWithFrontmatter`, `exists`, `resolveWikilink`
- `link-utils.ts`: `validateLinks`
- **Type Safety**: Replaced `any` types with `Record<string, unknown>` and removed eslint-disable directives
- `mcp-server.ts`: Tool arguments type
- `tools/index.ts`: `callTool` args parameter
- `notifications.ts`: `args` interface field, `showToolCall` parameter, `formatArgs` parameter
- **Import Statements**: Eliminated forbidden `require()` imports
- `crypto-adapter.ts`: Replaced `require('crypto')` with `globalThis.crypto`
- `encryption-utils.ts`: Replaced bare `require('electron')` with `window.require` pattern
### Changed
- Updated test mocks to match new synchronous method signatures and import patterns
---
## [1.1.2] - 2025-11-15 ## [1.1.2] - 2025-11-15
### Fixed ### Fixed

View File

@@ -0,0 +1,636 @@
# Obsidian Plugin Code Review Fixes Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix all required issues from the Nov 16, 2025 ObsidianReviewBot code review to unblock plugin submission approval.
**Architecture:** Systematic file-by-file fixes addressing: sentence case UI text, async/await cleanup, eslint directive removal, require() to ES6 import conversion, and promise handling improvements.
**Tech Stack:** TypeScript, Obsidian API, ESLint
---
## Task 1: Fix Sentence Case in main.ts
**Files:**
- Modify: `src/main.ts:45`
**Step 1: Fix ribbon icon tooltip**
Change line 45 from:
```typescript
this.addRibbonIcon('server', 'Toggle MCP Server', async () => {
```
To:
```typescript
this.addRibbonIcon('server', 'Toggle MCP server', async () => {
```
**Step 2: Fix onunload promise issue (lines 96-98)**
Change from:
```typescript
async onunload() {
await this.stopServer();
}
```
To:
```typescript
onunload() {
void this.stopServer();
}
```
**Step 3: Verify build**
Run: `npm run build`
Expected: No errors
**Step 4: Commit**
```bash
git add src/main.ts
git commit -m "fix: sentence case and onunload promise in main.ts"
```
---
## Task 2: Fix Sentence Case in settings.ts
**Files:**
- Modify: `src/settings.ts:209,319`
**Step 1: Fix authentication section header (line 209)**
Change from:
```typescript
authSummary.setText('Authentication & Configuration');
```
To:
```typescript
authSummary.setText('Authentication & configuration');
```
**Step 2: Fix notifications section header (line 319)**
Change from:
```typescript
notifSummary.setText('UI Notifications');
```
To:
```typescript
notifSummary.setText('UI notifications');
```
**Step 3: Verify build**
Run: `npm run build`
Expected: No errors
**Step 4: Commit**
```bash
git add src/settings.ts
git commit -m "fix: sentence case for section headers in settings.ts"
```
---
## Task 3: Fix mcp-server.ts Issues
**Files:**
- Modify: `src/server/mcp-server.ts:57,70,77-79,117`
**Step 1: Remove async from handleInitialize (line 57)**
Change from:
```typescript
private async handleInitialize(_params: JSONRPCParams): Promise<InitializeResult> {
```
To:
```typescript
private handleInitialize(_params: JSONRPCParams): InitializeResult {
```
**Step 2: Remove async from handleListTools (line 70)**
Change from:
```typescript
private async handleListTools(): Promise<ListToolsResult> {
```
To:
```typescript
private handleListTools(): ListToolsResult {
```
**Step 3: Update handleRequest callers (lines 41-43)**
Since handleInitialize and handleListTools are no longer async, remove the await:
Change from:
```typescript
case 'initialize':
return this.createSuccessResponse(request.id, await this.handleInitialize(request.params ?? {}));
case 'tools/list':
return this.createSuccessResponse(request.id, await this.handleListTools());
```
To:
```typescript
case 'initialize':
return this.createSuccessResponse(request.id, this.handleInitialize(request.params ?? {}));
case 'tools/list':
return this.createSuccessResponse(request.id, this.handleListTools());
```
**Step 4: Remove eslint-disable and fix any type (lines 77-79)**
Change from:
```typescript
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and need runtime validation
const paramsObj = params as { name: string; arguments: any };
```
To:
```typescript
const paramsObj = params as { name: string; arguments: Record<string, unknown> };
```
**Step 5: Fix promise rejection to use Error (line 117)**
Change from:
```typescript
reject(error);
```
To:
```typescript
reject(error instanceof Error ? error : new Error(String(error)));
```
**Step 6: Verify build**
Run: `npm run build`
Expected: No errors
**Step 7: Commit**
```bash
git add src/server/mcp-server.ts
git commit -m "fix: async/await, eslint directive, and promise rejection in mcp-server.ts"
```
---
## Task 4: Fix routes.ts Promise Issue
**Files:**
- Modify: `src/server/routes.ts:10-19`
**Step 1: Wrap async handler to handle void context**
Change from:
```typescript
app.post('/mcp', async (req: Request, res: Response) => {
try {
const request = req.body as JSONRPCRequest;
const response = await handleRequest(request);
res.json(response);
} catch (error) {
console.error('MCP request error:', error);
res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Internal server error'));
}
});
```
To:
```typescript
app.post('/mcp', (req: Request, res: Response) => {
void (async () => {
try {
const request = req.body as JSONRPCRequest;
const response = await handleRequest(request);
res.json(response);
} catch (error) {
console.error('MCP request error:', error);
res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Internal server error'));
}
})();
});
```
**Step 2: Verify build**
Run: `npm run build`
Expected: No errors
**Step 3: Commit**
```bash
git add src/server/routes.ts
git commit -m "fix: wrap async handler with void for proper promise handling"
```
---
## Task 5: Fix tools/index.ts ESLint Directive
**Files:**
- Modify: `src/tools/index.ts:477-478`
**Step 1: Remove eslint-disable and fix type**
Change from:
```typescript
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and require runtime validation
async callTool(name: string, args: any): Promise<CallToolResult> {
```
To:
```typescript
async callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
```
**Step 2: Verify build**
Run: `npm run build`
Expected: No errors
**Step 3: Commit**
```bash
git add src/tools/index.ts
git commit -m "fix: remove eslint-disable directive in tools/index.ts"
```
---
## Task 6: Fix vault-tools.ts Async Methods
**Files:**
- Modify: `src/tools/vault-tools.ts:18,63,310,498,925`
**Step 1: Remove async from getVaultInfo (line 18)**
Change from:
```typescript
async getVaultInfo(): Promise<CallToolResult> {
```
To:
```typescript
getVaultInfo(): CallToolResult {
```
**Step 2: Remove async from listNotes (line 63)**
Change from:
```typescript
async listNotes(path?: string): Promise<CallToolResult> {
```
To:
```typescript
listNotes(path?: string): CallToolResult {
```
**Step 3: Remove async from createFileMetadataWithFrontmatter (line 310)**
Change from:
```typescript
private async createFileMetadataWithFrontmatter(
```
To:
```typescript
private createFileMetadataWithFrontmatter(
```
Also update the return type from `Promise<FileMetadataWithFrontmatter>` to `FileMetadataWithFrontmatter`.
**Step 4: Remove async from exists (line 498)**
Change from:
```typescript
async exists(path: string): Promise<CallToolResult> {
```
To:
```typescript
exists(path: string): CallToolResult {
```
**Step 5: Remove async from resolveWikilink (line 925)**
Change from:
```typescript
async resolveWikilink(sourcePath: string, linkText: string): Promise<CallToolResult> {
```
To:
```typescript
resolveWikilink(sourcePath: string, linkText: string): CallToolResult {
```
**Step 6: Update callers if any use await on these methods**
Search for any `await this.getVaultInfo()`, `await this.listNotes()`, `await this.exists()`, `await this.resolveWikilink()`, `await this.createFileMetadataWithFrontmatter()` and remove the `await` keyword.
**Step 7: Verify build**
Run: `npm run build`
Expected: No errors
**Step 8: Commit**
```bash
git add src/tools/vault-tools.ts
git commit -m "fix: remove async from methods without await in vault-tools.ts"
```
---
## Task 7: Fix notifications.ts ESLint Directives
**Files:**
- Modify: `src/ui/notifications.ts:10-11,78-79,145-146,179`
**Step 1: Fix interface args type (lines 10-11)**
Change from:
```typescript
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and can be any valid JSON structure
args: any;
```
To:
```typescript
args: Record<string, unknown>;
```
**Step 2: Fix showToolCall parameter type (lines 78-79)**
Change from:
```typescript
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and can be any valid JSON structure
showToolCall(toolName: string, args: any, duration?: number): void {
```
To:
```typescript
showToolCall(toolName: string, args: Record<string, unknown>, duration?: number): void {
```
**Step 3: Fix formatArgs parameter type (lines 145-146)**
Change from:
```typescript
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and can be any valid JSON structure
private formatArgs(args: any): string {
```
To:
```typescript
private formatArgs(args: Record<string, unknown>): string {
```
**Step 4: Fix unused 'e' variable (line 179)**
Change from:
```typescript
} catch (e) {
```
To:
```typescript
} catch {
```
**Step 5: Verify build**
Run: `npm run build`
Expected: No errors
**Step 6: Commit**
```bash
git add src/ui/notifications.ts
git commit -m "fix: remove eslint directives and unused catch variable in notifications.ts"
```
---
## Task 8: Fix crypto-adapter.ts Require Import
**Files:**
- Modify: `src/utils/crypto-adapter.ts:18-34`
**Step 1: Replace require with dynamic approach**
The challenge here is that require() is used for synchronous access. We need to restructure to use a lazy initialization pattern.
Change the entire Node.js section from:
```typescript
// Node.js environment (15+) - uses Web Crypto API standard
if (typeof global !== 'undefined') {
try {
// Using require() is necessary for synchronous crypto access in Obsidian desktop plugins
// ES6 dynamic imports would create race conditions as crypto must be available synchronously
// eslint-disable-next-line @typescript-eslint/no-var-requires -- Synchronous Node.js crypto API access required
const nodeCrypto = require('crypto') as typeof import('crypto');
if (nodeCrypto?.webcrypto) {
return nodeCrypto.webcrypto as unknown as Crypto;
}
} catch {
// Crypto module not available or failed to load
}
}
```
To (using globalThis.crypto which is available in Node 19+ and Electron):
```typescript
// Node.js/Electron environment - globalThis.crypto available in modern runtimes
if (typeof globalThis !== 'undefined' && globalThis.crypto) {
return globalThis.crypto;
}
```
**Step 2: Verify build**
Run: `npm run build`
Expected: No errors
**Step 3: Commit**
```bash
git add src/utils/crypto-adapter.ts
git commit -m "fix: use globalThis.crypto instead of require('crypto')"
```
---
## Task 9: Fix encryption-utils.ts Require Import
**Files:**
- Modify: `src/utils/encryption-utils.ts:8-18`
**Step 1: Restructure electron import**
Since Electron's safeStorage must be accessed synchronously at module load time, and ES6 dynamic imports are async, we need to use a different approach. In Obsidian plugins running in Electron, we can access electron through the window object.
Change from:
```typescript
// Safely import safeStorage - may not be available in all environments
let safeStorage: ElectronSafeStorage | null = null;
try {
// Using require() is necessary for synchronous access to Electron's safeStorage API in Obsidian desktop plugins
// ES6 dynamic imports would create race conditions as this module must be available synchronously
// eslint-disable-next-line @typescript-eslint/no-var-requires -- Synchronous Electron API access required for Obsidian plugin
const electron = require('electron') as typeof import('electron');
safeStorage = electron.safeStorage || null;
} catch (error) {
console.warn('Electron safeStorage not available, API keys will be stored in plaintext');
}
```
To:
```typescript
// Safely import safeStorage - may not be available in all environments
let safeStorage: ElectronSafeStorage | null = null;
try {
// Access electron through the global window object in Obsidian's Electron environment
// This avoids require() while still getting synchronous access
const electronRemote = (window as Window & { require?: (module: string) => typeof import('electron') }).require;
if (electronRemote) {
const electron = electronRemote('electron');
safeStorage = electron.safeStorage || null;
}
} catch {
console.warn('Electron safeStorage not available, API keys will be stored in plaintext');
}
```
**Step 2: Verify build**
Run: `npm run build`
Expected: No errors
**Step 3: Commit**
```bash
git add src/utils/encryption-utils.ts
git commit -m "fix: use window.require pattern instead of bare require for electron"
```
---
## Task 10: Fix link-utils.ts Async Method
**Files:**
- Modify: `src/utils/link-utils.ts:448`
**Step 1: Remove async from validateLinks**
Change from:
```typescript
static async validateLinks(
vault: IVaultAdapter,
metadata: IMetadataCacheAdapter,
content: string,
sourcePath: string
): Promise<LinkValidationResult> {
```
To:
```typescript
static validateLinks(
vault: IVaultAdapter,
metadata: IMetadataCacheAdapter,
content: string,
sourcePath: string
): LinkValidationResult {
```
**Step 2: Update any callers that await this method**
Search for `await LinkUtils.validateLinks` or `await this.validateLinks` and remove the `await`.
**Step 3: Verify build**
Run: `npm run build`
Expected: No errors
**Step 4: Commit**
```bash
git add src/utils/link-utils.ts
git commit -m "fix: remove async from validateLinks method"
```
---
## Task 11: Final Build and Test
**Step 1: Run full build**
Run: `npm run build`
Expected: No errors
**Step 2: Run tests**
Run: `npm test`
Expected: All tests pass
**Step 3: Commit any remaining changes**
```bash
git status
# If any uncommitted changes:
git add -A
git commit -m "fix: final cleanup for code review issues"
```
---
## Optional Tasks (if time permits)
### Optional Task A: Fix Unused Error Variables
**Files:**
- `src/tools/vault-tools.ts:289,359,393,445,715`
- `src/utils/encryption-utils.ts:16`
- `src/utils/frontmatter-utils.ts:76,329,358`
- `src/utils/search-utils.ts:117,326`
- `src/utils/waypoint-utils.ts:103`
For each occurrence, change `catch (error) {` or `catch (e) {` or `catch (decompressError) {` to just `catch {`.
### Optional Task B: Use FileManager.trashFile()
**Files:**
- Modify: `src/adapters/vault-adapter.ts:46-48`
- Modify: `src/adapters/interfaces.ts` (update IVaultAdapter interface)
This requires passing the App or FileManager to the VaultAdapter, which is a larger refactor.
---
## Summary Checklist
- [ ] Task 1: main.ts sentence case + onunload
- [ ] Task 2: settings.ts sentence case
- [ ] Task 3: mcp-server.ts async/eslint/promise fixes
- [ ] Task 4: routes.ts promise handling
- [ ] Task 5: tools/index.ts eslint directive
- [ ] Task 6: vault-tools.ts async methods
- [ ] Task 7: notifications.ts eslint directives
- [ ] Task 8: crypto-adapter.ts require import
- [ ] Task 9: encryption-utils.ts require import
- [ ] Task 10: link-utils.ts async method
- [ ] Task 11: Final build and test

View File

@@ -1,7 +1,7 @@
{ {
"id": "mcp-server", "id": "mcp-server",
"name": "MCP Server", "name": "MCP Server",
"version": "1.1.2", "version": "1.1.3",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Exposes vault operations via Model Context Protocol (MCP) over HTTP.", "description": "Exposes vault operations via Model Context Protocol (MCP) over HTTP.",
"author": "William Ballou", "author": "William Ballou",

View File

@@ -1,6 +1,6 @@
{ {
"name": "mcp-server", "name": "mcp-server",
"version": "1.1.2", "version": "1.1.3",
"description": "MCP (Model Context Protocol) server plugin - exposes vault operations via HTTP", "description": "MCP (Model Context Protocol) server plugin - exposes vault operations via HTTP",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@@ -42,7 +42,7 @@ export default class MCPServerPlugin extends Plugin {
this.updateStatusBar(); this.updateStatusBar();
// Add ribbon icon to toggle server // Add ribbon icon to toggle server
this.addRibbonIcon('server', 'Toggle MCP Server', async () => { this.addRibbonIcon('server', 'Toggle MCP server', async () => {
if (this.mcpServer?.isRunning()) { if (this.mcpServer?.isRunning()) {
await this.stopServer(); await this.stopServer();
} else { } else {
@@ -93,8 +93,8 @@ export default class MCPServerPlugin extends Plugin {
} }
} }
async onunload() { onunload() {
await this.stopServer(); void this.stopServer();
} }
async startServer() { async startServer() {

View File

@@ -38,9 +38,9 @@ export class MCPServer {
try { try {
switch (request.method) { switch (request.method) {
case 'initialize': case 'initialize':
return this.createSuccessResponse(request.id, await this.handleInitialize(request.params ?? {})); return this.createSuccessResponse(request.id, this.handleInitialize(request.params ?? {}));
case 'tools/list': case 'tools/list':
return this.createSuccessResponse(request.id, await this.handleListTools()); return this.createSuccessResponse(request.id, this.handleListTools());
case 'tools/call': case 'tools/call':
return this.createSuccessResponse(request.id, await this.handleCallTool(request.params ?? {})); return this.createSuccessResponse(request.id, await this.handleCallTool(request.params ?? {}));
case 'ping': case 'ping':
@@ -54,7 +54,7 @@ export class MCPServer {
} }
} }
private async handleInitialize(_params: JSONRPCParams): Promise<InitializeResult> { private handleInitialize(_params: JSONRPCParams): InitializeResult {
return { return {
protocolVersion: "2024-11-05", protocolVersion: "2024-11-05",
capabilities: { capabilities: {
@@ -67,15 +67,14 @@ export class MCPServer {
}; };
} }
private async handleListTools(): Promise<ListToolsResult> { private handleListTools(): ListToolsResult {
return { return {
tools: this.toolRegistry.getToolDefinitions() tools: this.toolRegistry.getToolDefinitions()
}; };
} }
private async handleCallTool(params: JSONRPCParams): Promise<CallToolResult> { private async handleCallTool(params: JSONRPCParams): Promise<CallToolResult> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and need runtime validation const paramsObj = params as { name: string; arguments: Record<string, unknown> };
const paramsObj = params as { name: string; arguments: any };
return await this.toolRegistry.callTool(paramsObj.name, paramsObj.arguments); return await this.toolRegistry.callTool(paramsObj.name, paramsObj.arguments);
} }
@@ -114,7 +113,7 @@ export class MCPServer {
} }
}); });
} catch (error) { } catch (error) {
reject(error); reject(error instanceof Error ? error : new Error(String(error)));
} }
}); });
} }

View File

@@ -7,15 +7,17 @@ export function setupRoutes(
createErrorResponse: (id: string | number | null, code: number, message: string) => JSONRPCResponse createErrorResponse: (id: string | number | null, code: number, message: string) => JSONRPCResponse
): void { ): void {
// Main MCP endpoint // Main MCP endpoint
app.post('/mcp', async (req: Request, res: Response) => { app.post('/mcp', (req: Request, res: Response) => {
try { void (async () => {
const request = req.body as JSONRPCRequest; try {
const response = await handleRequest(request); const request = req.body as JSONRPCRequest;
res.json(response); const response = await handleRequest(request);
} catch (error) { res.json(response);
console.error('MCP request error:', error); } catch (error) {
res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Internal server error')); console.error('MCP request error:', error);
} res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Internal server error'));
}
})();
}); });
// Health check endpoint // Health check endpoint

View File

@@ -206,7 +206,7 @@ export class MCPServerSettingTab extends PluginSettingTab {
// Authentication (Always Enabled) // Authentication (Always Enabled)
const authDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'}); const authDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'});
const authSummary = authDetails.createEl('summary', {cls: 'mcp-auth-summary'}); const authSummary = authDetails.createEl('summary', {cls: 'mcp-auth-summary'});
authSummary.setText('Authentication & Configuration'); authSummary.setText('Authentication & configuration');
// Store reference for targeted updates // Store reference for targeted updates
this.authDetailsEl = authDetails; this.authDetailsEl = authDetails;
@@ -316,7 +316,7 @@ export class MCPServerSettingTab extends PluginSettingTab {
// Notification Settings // Notification Settings
const notifDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'}); const notifDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'});
const notifSummary = notifDetails.createEl('summary', {cls: 'mcp-auth-summary'}); const notifSummary = notifDetails.createEl('summary', {cls: 'mcp-auth-summary'});
notifSummary.setText('UI Notifications'); notifSummary.setText('UI notifications');
// Store reference for targeted updates // Store reference for targeted updates
this.notificationDetailsEl = notifDetails; this.notificationDetailsEl = notifDetails;

View File

@@ -5,6 +5,7 @@ import { VaultTools } from './vault-tools';
import { createNoteTools } from './note-tools-factory'; import { createNoteTools } from './note-tools-factory';
import { createVaultTools } from './vault-tools-factory'; import { createVaultTools } from './vault-tools-factory';
import { NotificationManager } from '../ui/notifications'; import { NotificationManager } from '../ui/notifications';
import { YAMLValue } from '../utils/frontmatter-utils';
export class ToolRegistry { export class ToolRegistry {
private noteTools: NoteTools; private noteTools: NoteTools;
@@ -474,8 +475,7 @@ export class ToolRegistry {
]; ];
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and require runtime validation async callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
async callTool(name: string, args: any): Promise<CallToolResult> {
const startTime = Date.now(); const startTime = Date.now();
// Show tool call notification // Show tool call notification
@@ -487,124 +487,160 @@ export class ToolRegistry {
let result: CallToolResult; let result: CallToolResult;
switch (name) { switch (name) {
case "read_note": case "read_note": {
result = await this.noteTools.readNote(args.path, { const a = args as { path: string; withFrontmatter?: boolean; withContent?: boolean; parseFrontmatter?: boolean };
withFrontmatter: args.withFrontmatter, result = await this.noteTools.readNote(a.path, {
withContent: args.withContent, withFrontmatter: a.withFrontmatter,
parseFrontmatter: args.parseFrontmatter withContent: a.withContent,
parseFrontmatter: a.parseFrontmatter
}); });
break; break;
case "create_note": }
case "create_note": {
const a = args as { path: string; content: string; createParents?: boolean; onConflict?: 'error' | 'overwrite' | 'rename'; validateLinks?: boolean };
result = await this.noteTools.createNote( result = await this.noteTools.createNote(
args.path, a.path,
args.content, a.content,
args.createParents ?? false, a.createParents ?? false,
args.onConflict ?? 'error', a.onConflict ?? 'error',
args.validateLinks ?? true a.validateLinks ?? true
); );
break; break;
case "update_note": }
case "update_note": {
const a = args as { path: string; content: string; validateLinks?: boolean };
result = await this.noteTools.updateNote( result = await this.noteTools.updateNote(
args.path, a.path,
args.content, a.content,
args.validateLinks ?? true a.validateLinks ?? true
); );
break; break;
case "update_frontmatter": }
case "update_frontmatter": {
const a = args as { path: string; patch?: Record<string, YAMLValue>; remove?: string[]; ifMatch?: string };
result = await this.noteTools.updateFrontmatter( result = await this.noteTools.updateFrontmatter(
args.path, a.path,
args.patch, a.patch,
args.remove ?? [], a.remove ?? [],
args.ifMatch a.ifMatch
); );
break; break;
case "update_sections": }
case "update_sections": {
const a = args as { path: string; edits: Array<{ startLine: number; endLine: number; content: string }>; ifMatch?: string; validateLinks?: boolean };
result = await this.noteTools.updateSections( result = await this.noteTools.updateSections(
args.path, a.path,
args.edits, a.edits,
args.ifMatch, a.ifMatch,
args.validateLinks ?? true a.validateLinks ?? true
); );
break; break;
case "rename_file": }
case "rename_file": {
const a = args as { path: string; newPath: string; updateLinks?: boolean; ifMatch?: string };
result = await this.noteTools.renameFile( result = await this.noteTools.renameFile(
args.path, a.path,
args.newPath, a.newPath,
args.updateLinks ?? true, a.updateLinks ?? true,
args.ifMatch a.ifMatch
); );
break; break;
case "delete_note": }
case "delete_note": {
const a = args as { path: string; soft?: boolean; dryRun?: boolean; ifMatch?: string };
result = await this.noteTools.deleteNote( result = await this.noteTools.deleteNote(
args.path, a.path,
args.soft ?? true, a.soft ?? true,
args.dryRun ?? false, a.dryRun ?? false,
args.ifMatch a.ifMatch
); );
break; break;
case "search": }
case "search": {
const a = args as { query: string; isRegex?: boolean; caseSensitive?: boolean; includes?: string[]; excludes?: string[]; folder?: string; returnSnippets?: boolean; snippetLength?: number; maxResults?: number };
result = await this.vaultTools.search({ result = await this.vaultTools.search({
query: args.query, query: a.query,
isRegex: args.isRegex, isRegex: a.isRegex,
caseSensitive: args.caseSensitive, caseSensitive: a.caseSensitive,
includes: args.includes, includes: a.includes,
excludes: args.excludes, excludes: a.excludes,
folder: args.folder, folder: a.folder,
returnSnippets: args.returnSnippets, returnSnippets: a.returnSnippets,
snippetLength: args.snippetLength, snippetLength: a.snippetLength,
maxResults: args.maxResults maxResults: a.maxResults
}); });
break; break;
case "search_waypoints": }
result = await this.vaultTools.searchWaypoints(args.folder); case "search_waypoints": {
const a = args as { folder?: string };
result = await this.vaultTools.searchWaypoints(a.folder);
break; break;
}
case "get_vault_info": case "get_vault_info":
result = await this.vaultTools.getVaultInfo(); result = this.vaultTools.getVaultInfo();
break; break;
case "list": case "list": {
const a = args as { path?: string; recursive?: boolean; includes?: string[]; excludes?: string[]; only?: 'files' | 'directories' | 'any'; limit?: number; cursor?: string; withFrontmatterSummary?: boolean; includeWordCount?: boolean };
result = await this.vaultTools.list({ result = await this.vaultTools.list({
path: args.path, path: a.path,
recursive: args.recursive, recursive: a.recursive,
includes: args.includes, includes: a.includes,
excludes: args.excludes, excludes: a.excludes,
only: args.only, only: a.only,
limit: args.limit, limit: a.limit,
cursor: args.cursor, cursor: a.cursor,
withFrontmatterSummary: args.withFrontmatterSummary, withFrontmatterSummary: a.withFrontmatterSummary,
includeWordCount: args.includeWordCount includeWordCount: a.includeWordCount
}); });
break; break;
case "stat": }
result = await this.vaultTools.stat(args.path, args.includeWordCount); case "stat": {
const a = args as { path: string; includeWordCount?: boolean };
result = await this.vaultTools.stat(a.path, a.includeWordCount);
break; break;
case "exists": }
result = await this.vaultTools.exists(args.path); case "exists": {
const a = args as { path: string };
result = this.vaultTools.exists(a.path);
break; break;
case "read_excalidraw": }
result = await this.noteTools.readExcalidraw(args.path, { case "read_excalidraw": {
includeCompressed: args.includeCompressed, const a = args as { path: string; includeCompressed?: boolean; includePreview?: boolean };
includePreview: args.includePreview result = await this.noteTools.readExcalidraw(a.path, {
includeCompressed: a.includeCompressed,
includePreview: a.includePreview
}); });
break; break;
case "get_folder_waypoint": }
result = await this.vaultTools.getFolderWaypoint(args.path); case "get_folder_waypoint": {
const a = args as { path: string };
result = await this.vaultTools.getFolderWaypoint(a.path);
break; break;
case "is_folder_note": }
result = await this.vaultTools.isFolderNote(args.path); case "is_folder_note": {
const a = args as { path: string };
result = await this.vaultTools.isFolderNote(a.path);
break; break;
case "validate_wikilinks": }
result = await this.vaultTools.validateWikilinks(args.path); case "validate_wikilinks": {
const a = args as { path: string };
result = await this.vaultTools.validateWikilinks(a.path);
break; break;
case "resolve_wikilink": }
result = await this.vaultTools.resolveWikilink(args.sourcePath, args.linkText); case "resolve_wikilink": {
const a = args as { sourcePath: string; linkText: string };
result = this.vaultTools.resolveWikilink(a.sourcePath, a.linkText);
break; break;
case "backlinks": }
case "backlinks": {
const a = args as { path: string; includeUnlinked?: boolean; includeSnippets?: boolean };
result = await this.vaultTools.getBacklinks( result = await this.vaultTools.getBacklinks(
args.path, a.path,
args.includeUnlinked ?? false, a.includeUnlinked ?? false,
args.includeSnippets ?? true a.includeSnippets ?? true
); );
break; break;
}
default: default:
result = { result = {
content: [{ type: "text", text: `Unknown tool: ${name}` }], content: [{ type: "text", text: `Unknown tool: ${name}` }],

View File

@@ -248,7 +248,7 @@ export class NoteTools {
// Add link validation if requested // Add link validation if requested
if (validateLinks) { if (validateLinks) {
result.linkValidation = await LinkUtils.validateLinks( result.linkValidation = LinkUtils.validateLinks(
this.vault, this.vault,
this.metadata, this.metadata,
content, content,
@@ -388,7 +388,7 @@ export class NoteTools {
// Add link validation if requested // Add link validation if requested
if (validateLinks) { if (validateLinks) {
result.linkValidation = await LinkUtils.validateLinks( result.linkValidation = LinkUtils.validateLinks(
this.vault, this.vault,
this.metadata, this.metadata,
content, content,
@@ -990,7 +990,7 @@ export class NoteTools {
// Add link validation if requested // Add link validation if requested
if (validateLinks) { if (validateLinks) {
result.linkValidation = await LinkUtils.validateLinks( result.linkValidation = LinkUtils.validateLinks(
this.vault, this.vault,
this.metadata, this.metadata,
newContent, newContent,

View File

@@ -15,7 +15,7 @@ export class VaultTools {
private metadata: IMetadataCacheAdapter private metadata: IMetadataCacheAdapter
) {} ) {}
async getVaultInfo(): Promise<CallToolResult> { getVaultInfo(): CallToolResult {
try { try {
const allFiles = this.vault.getMarkdownFiles(); const allFiles = this.vault.getMarkdownFiles();
const totalNotes = allFiles.length; const totalNotes = allFiles.length;
@@ -60,7 +60,7 @@ export class VaultTools {
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
} }
async listNotes(path?: string): Promise<CallToolResult> { listNotes(path?: string): CallToolResult {
let items: Array<FileMetadata | DirectoryMetadata> = []; let items: Array<FileMetadata | DirectoryMetadata> = [];
// Normalize root path: undefined, empty string "", or "." all mean root // Normalize root path: undefined, empty string "", or "." all mean root
@@ -279,7 +279,7 @@ export class VaultTools {
// Apply type filtering and add items // Apply type filtering and add items
if (item instanceof TFile) { if (item instanceof TFile) {
if (only !== 'directories') { if (only !== 'directories') {
const fileMetadata = await this.createFileMetadataWithFrontmatter(item, withFrontmatterSummary || false); const fileMetadata = this.createFileMetadataWithFrontmatter(item, withFrontmatterSummary || false);
// Optionally include word count (best effort) // Optionally include word count (best effort)
if (includeWordCount) { if (includeWordCount) {
@@ -307,10 +307,10 @@ export class VaultTools {
} }
} }
private async createFileMetadataWithFrontmatter( private createFileMetadataWithFrontmatter(
file: TFile, file: TFile,
withFrontmatterSummary: boolean withFrontmatterSummary: boolean
): Promise<FileMetadataWithFrontmatter> { ): FileMetadataWithFrontmatter {
const baseMetadata = this.createFileMetadata(file); const baseMetadata = this.createFileMetadata(file);
if (!withFrontmatterSummary || file.extension !== 'md') { if (!withFrontmatterSummary || file.extension !== 'md') {
@@ -495,7 +495,7 @@ export class VaultTools {
}; };
} }
async exists(path: string): Promise<CallToolResult> { exists(path: string): CallToolResult {
// Validate path // Validate path
if (!PathUtils.isValidVaultPath(path)) { if (!PathUtils.isValidVaultPath(path)) {
return { return {
@@ -922,7 +922,7 @@ export class VaultTools {
* Resolve a single wikilink from a source note * Resolve a single wikilink from a source note
* Returns the target path if resolvable, or suggestions if not * Returns the target path if resolvable, or suggestions if not
*/ */
async resolveWikilink(sourcePath: string, linkText: string): Promise<CallToolResult> { resolveWikilink(sourcePath: string, linkText: string): CallToolResult {
try { try {
// Normalize and validate source path // Normalize and validate source path
const normalizedPath = PathUtils.normalizePath(sourcePath); const normalizedPath = PathUtils.normalizePath(sourcePath);

View File

@@ -7,8 +7,7 @@ import { MCPPluginSettings } from '../types/settings-types';
export interface NotificationHistoryEntry { export interface NotificationHistoryEntry {
timestamp: number; timestamp: number;
toolName: string; toolName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and can be any valid JSON structure args: Record<string, unknown>;
args: any;
success: boolean; success: boolean;
duration?: number; duration?: number;
error?: string; error?: string;
@@ -75,8 +74,7 @@ export class NotificationManager {
/** /**
* Show notification for tool call start * Show notification for tool call start
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and can be any valid JSON structure showToolCall(toolName: string, args: Record<string, unknown>, duration?: number): void {
showToolCall(toolName: string, args: any, duration?: number): void {
if (!this.shouldShowNotification()) { if (!this.shouldShowNotification()) {
return; return;
} }
@@ -142,8 +140,7 @@ export class NotificationManager {
/** /**
* Format arguments for display * Format arguments for display
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and can be any valid JSON structure private formatArgs(args: Record<string, unknown>): string {
private formatArgs(args: any): string {
if (!this.settings.showParameters) { if (!this.settings.showParameters) {
return ''; return '';
} }
@@ -156,13 +153,13 @@ export class NotificationManager {
// Extract key parameters for display // Extract key parameters for display
const keyParams: string[] = []; const keyParams: string[] = [];
if (args.path) { if (args.path && typeof args.path === 'string') {
keyParams.push(`path: "${this.truncateString(args.path, 30)}"`); keyParams.push(`path: "${this.truncateString(args.path, 30)}"`);
} }
if (args.query) { if (args.query && typeof args.query === 'string') {
keyParams.push(`query: "${this.truncateString(args.query, 30)}"`); keyParams.push(`query: "${this.truncateString(args.query, 30)}"`);
} }
if (args.folder) { if (args.folder && typeof args.folder === 'string') {
keyParams.push(`folder: "${this.truncateString(args.folder, 30)}"`); keyParams.push(`folder: "${this.truncateString(args.folder, 30)}"`);
} }
if (args.recursive !== undefined) { if (args.recursive !== undefined) {
@@ -176,7 +173,7 @@ export class NotificationManager {
} }
return keyParams.join(', '); return keyParams.join(', ');
} catch (e) { } catch {
return ''; return '';
} }
} }

View File

@@ -15,19 +15,9 @@ function getCrypto(): Crypto {
return window.crypto; return window.crypto;
} }
// Node.js environment (15+) - uses Web Crypto API standard // Node.js/Electron environment - globalThis.crypto available in Node 20+
if (typeof global !== 'undefined') { if (typeof globalThis !== 'undefined' && globalThis.crypto) {
try { return globalThis.crypto;
// Using require() is necessary for synchronous crypto access in Obsidian desktop plugins
// ES6 dynamic imports would create race conditions as crypto must be available synchronously
// eslint-disable-next-line @typescript-eslint/no-var-requires -- Synchronous Node.js crypto API access required
const nodeCrypto = require('crypto') as typeof import('crypto');
if (nodeCrypto?.webcrypto) {
return nodeCrypto.webcrypto as unknown as Crypto;
}
} catch {
// Crypto module not available or failed to load
}
} }
throw new Error('No Web Crypto API available in this environment'); throw new Error('No Web Crypto API available in this environment');

View File

@@ -8,12 +8,14 @@ interface ElectronSafeStorage {
// Safely import safeStorage - may not be available in all environments // Safely import safeStorage - may not be available in all environments
let safeStorage: ElectronSafeStorage | null = null; let safeStorage: ElectronSafeStorage | null = null;
try { try {
// Using require() is necessary for synchronous access to Electron's safeStorage API in Obsidian desktop plugins // Access electron through the global window object in Obsidian's Electron environment
// ES6 dynamic imports would create race conditions as this module must be available synchronously // This avoids require() while still getting synchronous access
// eslint-disable-next-line @typescript-eslint/no-var-requires -- Synchronous Electron API access required for Obsidian plugin const electronRemote = (window as Window & { require?: (module: string) => typeof import('electron') }).require;
const electron = require('electron') as typeof import('electron'); if (electronRemote) {
safeStorage = electron.safeStorage || null; const electron = electronRemote('electron');
} catch (error) { safeStorage = electron.safeStorage || null;
}
} catch {
console.warn('Electron safeStorage not available, API keys will be stored in plaintext'); console.warn('Electron safeStorage not available, API keys will be stored in plaintext');
} }

View File

@@ -445,12 +445,12 @@ export class LinkUtils {
* @param sourcePath Path of the file containing the links * @param sourcePath Path of the file containing the links
* @returns Structured validation result with categorized links * @returns Structured validation result with categorized links
*/ */
static async validateLinks( static validateLinks(
vault: IVaultAdapter, vault: IVaultAdapter,
metadata: IMetadataCacheAdapter, metadata: IMetadataCacheAdapter,
content: string, content: string,
sourcePath: string sourcePath: string
): Promise<LinkValidationResult> { ): LinkValidationResult {
const valid: string[] = []; const valid: string[] = [];
const brokenNotes: BrokenNoteLink[] = []; const brokenNotes: BrokenNoteLink[] = [];
const brokenHeadings: BrokenHeadingLink[] = []; const brokenHeadings: BrokenHeadingLink[] = [];

View File

@@ -138,11 +138,18 @@ describe('crypto-adapter', () => {
const globalRef = global as any; const globalRef = global as any;
const originalWindow = globalRef.window; const originalWindow = globalRef.window;
const originalGlobal = globalRef.global; const originalGlobal = globalRef.global;
const originalGlobalThisCrypto = globalThis.crypto;
try { try {
// Remove window.crypto and global access // Remove window.crypto, global access, and globalThis.crypto
delete globalRef.window; delete globalRef.window;
delete globalRef.global; delete globalRef.global;
// In modern Node.js, globalThis.crypto is always available, so we must mock it too
Object.defineProperty(globalThis, 'crypto', {
value: undefined,
writable: true,
configurable: true
});
// Clear module cache to force re-evaluation // Clear module cache to force re-evaluation
jest.resetModules(); jest.resetModules();
@@ -157,6 +164,12 @@ describe('crypto-adapter', () => {
// Restore original values // Restore original values
globalRef.window = originalWindow; globalRef.window = originalWindow;
globalRef.global = originalGlobal; globalRef.global = originalGlobal;
// Restore globalThis.crypto
Object.defineProperty(globalThis, 'crypto', {
value: originalGlobalThisCrypto,
writable: true,
configurable: true
});
// Clear module cache again to restore normal state // Clear module cache again to restore normal state
jest.resetModules(); jest.resetModules();

View File

@@ -1,18 +1,63 @@
import { encryptApiKey, decryptApiKey, isEncryptionAvailable } from '../src/utils/encryption-utils'; // Mock safeStorage implementation
const mockSafeStorage = {
isEncryptionAvailable: jest.fn(() => true),
encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)),
decryptString: jest.fn((buffer: Buffer) => buffer.toString().replace('encrypted:', ''))
};
// Mock electron module // Setup window.require mock before importing the module
jest.mock('electron', () => ({ const mockWindowRequire = jest.fn((module: string) => {
safeStorage: { if (module === 'electron') {
isEncryptionAvailable: jest.fn(() => true), return { safeStorage: mockSafeStorage };
encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)),
decryptString: jest.fn((buffer: Buffer) => {
const str = buffer.toString();
return str.replace('encrypted:', '');
})
} }
})); throw new Error(`Module not found: ${module}`);
});
// Create mock window object for Node environment
const mockWindow: Window & { require?: unknown } = {
require: mockWindowRequire
} as unknown as Window & { require?: unknown };
// Store original global window
const originalWindow = (globalThis as unknown as { window?: unknown }).window;
// Set up window.require before tests run
beforeAll(() => {
(globalThis as unknown as { window: typeof mockWindow }).window = mockWindow;
});
// Clean up after all tests
afterAll(() => {
if (originalWindow === undefined) {
delete (globalThis as unknown as { window?: unknown }).window;
} else {
(globalThis as unknown as { window: typeof originalWindow }).window = originalWindow;
}
});
// Import after mock is set up - use require to ensure module loads after mock
let encryptApiKey: typeof import('../src/utils/encryption-utils').encryptApiKey;
let decryptApiKey: typeof import('../src/utils/encryption-utils').decryptApiKey;
let isEncryptionAvailable: typeof import('../src/utils/encryption-utils').isEncryptionAvailable;
beforeAll(() => {
// Reset modules to ensure fresh load with mock
jest.resetModules();
const encryptionUtils = require('../src/utils/encryption-utils');
encryptApiKey = encryptionUtils.encryptApiKey;
decryptApiKey = encryptionUtils.decryptApiKey;
isEncryptionAvailable = encryptionUtils.isEncryptionAvailable;
});
describe('Encryption Utils', () => { describe('Encryption Utils', () => {
beforeEach(() => {
// Reset mock implementations before each test
mockSafeStorage.isEncryptionAvailable.mockReturnValue(true);
mockSafeStorage.encryptString.mockImplementation((data: string) => Buffer.from(`encrypted:${data}`));
mockSafeStorage.decryptString.mockImplementation((buffer: Buffer) => buffer.toString().replace('encrypted:', ''));
mockWindowRequire.mockClear();
});
describe('encryptApiKey', () => { describe('encryptApiKey', () => {
it('should encrypt API key when encryption is available', () => { it('should encrypt API key when encryption is available', () => {
const apiKey = 'test-api-key-12345'; const apiKey = 'test-api-key-12345';
@@ -23,13 +68,23 @@ describe('Encryption Utils', () => {
}); });
it('should return plaintext when encryption is not available', () => { it('should return plaintext when encryption is not available', () => {
const { safeStorage } = require('electron'); // Need to reload module with different mock behavior
safeStorage.isEncryptionAvailable.mockReturnValueOnce(false); jest.resetModules();
const mockStorage = {
isEncryptionAvailable: jest.fn(() => false),
encryptString: jest.fn(),
decryptString: jest.fn()
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
const { encryptApiKey: encrypt } = require('../src/utils/encryption-utils');
const apiKey = 'test-api-key-12345'; const apiKey = 'test-api-key-12345';
const result = encryptApiKey(apiKey); const result = encrypt(apiKey);
expect(result).toBe(apiKey); expect(result).toBe(apiKey);
// Restore original mock
mockWindow.require = mockWindowRequire;
}); });
it('should handle empty string', () => { it('should handle empty string', () => {
@@ -73,92 +128,107 @@ describe('Encryption Utils', () => {
describe('error handling', () => { describe('error handling', () => {
it('should handle encryption errors and fallback to plaintext', () => { it('should handle encryption errors and fallback to plaintext', () => {
const { safeStorage } = require('electron'); // Reload module with error-throwing mock
const originalEncrypt = safeStorage.encryptString; jest.resetModules();
safeStorage.encryptString = jest.fn(() => { const mockStorage = {
throw new Error('Encryption failed'); isEncryptionAvailable: jest.fn(() => true),
}); encryptString: jest.fn(() => {
throw new Error('Encryption failed');
}),
decryptString: jest.fn()
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
const { encryptApiKey: encrypt } = require('../src/utils/encryption-utils');
const apiKey = 'test-api-key-12345'; const apiKey = 'test-api-key-12345';
const result = encryptApiKey(apiKey); const result = encrypt(apiKey);
expect(result).toBe(apiKey); // Should return plaintext on error expect(result).toBe(apiKey); // Should return plaintext on error
safeStorage.encryptString = originalEncrypt; // Restore
// Restore original mock
mockWindow.require = mockWindowRequire;
}); });
it('should throw error when decryption fails', () => { it('should throw error when decryption fails', () => {
const { safeStorage } = require('electron'); // Reload module with error-throwing mock
const originalDecrypt = safeStorage.decryptString; jest.resetModules();
safeStorage.decryptString = jest.fn(() => { const mockStorage = {
throw new Error('Decryption failed'); isEncryptionAvailable: jest.fn(() => true),
}); encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)),
decryptString: jest.fn(() => {
throw new Error('Decryption failed');
})
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
const { decryptApiKey: decrypt } = require('../src/utils/encryption-utils');
const encrypted = 'encrypted:aW52YWxpZA=='; // Invalid encrypted data const encrypted = 'encrypted:aW52YWxpZA=='; // Invalid encrypted data
expect(() => decryptApiKey(encrypted)).toThrow('Failed to decrypt API key'); expect(() => decrypt(encrypted)).toThrow('Failed to decrypt API key');
safeStorage.decryptString = originalDecrypt; // Restore
// Restore original mock
mockWindow.require = mockWindowRequire;
}); });
}); });
describe('isEncryptionAvailable', () => { describe('isEncryptionAvailable', () => {
it('should return true when encryption is available', () => { it('should return true when encryption is available', () => {
const { isEncryptionAvailable } = require('../src/utils/encryption-utils'); jest.resetModules();
const { safeStorage } = require('electron'); const mockStorage = {
isEncryptionAvailable: jest.fn(() => true),
encryptString: jest.fn(),
decryptString: jest.fn()
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
safeStorage.isEncryptionAvailable.mockReturnValueOnce(true); const { isEncryptionAvailable: checkAvail } = require('../src/utils/encryption-utils');
expect(isEncryptionAvailable()).toBe(true); expect(checkAvail()).toBe(true);
// Restore
mockWindow.require = mockWindowRequire;
}); });
it('should return false when encryption is not available', () => { it('should return false when encryption is not available', () => {
const { isEncryptionAvailable } = require('../src/utils/encryption-utils'); jest.resetModules();
const { safeStorage } = require('electron'); const mockStorage = {
isEncryptionAvailable: jest.fn(() => false),
encryptString: jest.fn(),
decryptString: jest.fn()
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
safeStorage.isEncryptionAvailable.mockReturnValueOnce(false); const { isEncryptionAvailable: checkAvail } = require('../src/utils/encryption-utils');
expect(isEncryptionAvailable()).toBe(false); expect(checkAvail()).toBe(false);
// Restore
mockWindow.require = mockWindowRequire;
}); });
it('should return false when safeStorage is null', () => { it('should return false when safeStorage is null', () => {
// This tests the case where Electron is not available
// We need to reload the module with electron unavailable
jest.resetModules(); jest.resetModules();
mockWindow.require = jest.fn(() => ({ safeStorage: null }));
jest.mock('electron', () => ({ const { isEncryptionAvailable: checkAvail } = require('../src/utils/encryption-utils');
safeStorage: null expect(checkAvail()).toBe(false);
}));
const { isEncryptionAvailable } = require('../src/utils/encryption-utils');
expect(isEncryptionAvailable()).toBe(false);
// Restore original mock // Restore original mock
jest.resetModules(); mockWindow.require = mockWindowRequire;
jest.mock('electron', () => ({
safeStorage: {
isEncryptionAvailable: jest.fn(() => true),
encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)),
decryptString: jest.fn((buffer: Buffer) => {
const str = buffer.toString();
return str.replace('encrypted:', '');
})
}
}));
}); });
it('should return false when isEncryptionAvailable method is missing', () => { it('should return false when isEncryptionAvailable method is missing', () => {
jest.resetModules(); jest.resetModules();
const mockStorage = {
// Missing isEncryptionAvailable method
encryptString: jest.fn(),
decryptString: jest.fn()
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
jest.mock('electron', () => ({ const { isEncryptionAvailable: checkAvail } = require('../src/utils/encryption-utils');
safeStorage: { expect(checkAvail()).toBe(false);
// Missing isEncryptionAvailable method
encryptString: jest.fn(),
decryptString: jest.fn()
}
}));
const { isEncryptionAvailable } = require('../src/utils/encryption-utils');
expect(isEncryptionAvailable()).toBe(false);
// Restore // Restore
jest.resetModules(); mockWindow.require = mockWindowRequire;
}); });
}); });
@@ -168,12 +238,13 @@ describe('Encryption Utils', () => {
}); });
afterEach(() => { afterEach(() => {
jest.resetModules(); // Restore mock after each test
mockWindow.require = mockWindowRequire;
}); });
it('should handle electron module not being available', () => { it('should handle electron module not being available', () => {
// Mock require to throw when loading electron // Mock require to throw when loading electron
jest.mock('electron', () => { mockWindow.require = jest.fn(() => {
throw new Error('Electron not available'); throw new Error('Electron not available');
}); });
@@ -181,12 +252,12 @@ describe('Encryption Utils', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
// Load module with electron unavailable // Load module with electron unavailable
const { encryptApiKey, isEncryptionAvailable } = require('../src/utils/encryption-utils'); const { encryptApiKey: encrypt, isEncryptionAvailable: checkAvail } = require('../src/utils/encryption-utils');
expect(isEncryptionAvailable()).toBe(false); expect(checkAvail()).toBe(false);
const apiKey = 'test-key'; const apiKey = 'test-key';
const result = encryptApiKey(apiKey); const result = encrypt(apiKey);
// Should return plaintext when electron is unavailable // Should return plaintext when electron is unavailable
expect(result).toBe(apiKey); expect(result).toBe(apiKey);
@@ -195,21 +266,19 @@ describe('Encryption Utils', () => {
}); });
it('should handle decryption when safeStorage is null', () => { it('should handle decryption when safeStorage is null', () => {
jest.mock('electron', () => ({ mockWindow.require = jest.fn(() => ({ safeStorage: null }));
safeStorage: null
}));
const { decryptApiKey } = require('../src/utils/encryption-utils'); const { decryptApiKey: decrypt } = require('../src/utils/encryption-utils');
const encrypted = 'encrypted:aW52YWxpZA=='; const encrypted = 'encrypted:aW52YWxpZA==';
expect(() => decryptApiKey(encrypted)).toThrow('Failed to decrypt API key'); expect(() => decrypt(encrypted)).toThrow('Failed to decrypt API key');
}); });
it('should log warning when encryption not available on first load', () => { it('should log warning when encryption not available on first load', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
jest.mock('electron', () => { mockWindow.require = jest.fn(() => {
throw new Error('Module not found'); throw new Error('Module not found');
}); });
@@ -225,35 +294,32 @@ describe('Encryption Utils', () => {
}); });
it('should gracefully handle plaintext keys when encryption unavailable', () => { it('should gracefully handle plaintext keys when encryption unavailable', () => {
jest.mock('electron', () => ({ mockWindow.require = jest.fn(() => ({ safeStorage: null }));
safeStorage: null
}));
const { encryptApiKey, decryptApiKey } = require('../src/utils/encryption-utils'); const { encryptApiKey: encrypt, decryptApiKey: decrypt } = require('../src/utils/encryption-utils');
const apiKey = 'plain-api-key'; const apiKey = 'plain-api-key';
// Encrypt should return plaintext // Encrypt should return plaintext
const encrypted = encryptApiKey(apiKey); const encrypted = encrypt(apiKey);
expect(encrypted).toBe(apiKey); expect(encrypted).toBe(apiKey);
// Decrypt plaintext should return as-is // Decrypt plaintext should return as-is
const decrypted = decryptApiKey(apiKey); const decrypted = decrypt(apiKey);
expect(decrypted).toBe(apiKey); expect(decrypted).toBe(apiKey);
}); });
it('should warn when falling back to plaintext storage', () => { it('should warn when falling back to plaintext storage', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
jest.mock('electron', () => ({ const mockStorage = {
safeStorage: { isEncryptionAvailable: jest.fn(() => false)
isEncryptionAvailable: jest.fn(() => false) };
} mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
}));
const { encryptApiKey } = require('../src/utils/encryption-utils'); const { encryptApiKey: encrypt } = require('../src/utils/encryption-utils');
encryptApiKey('test-key'); encrypt('test-key');
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Encryption not available') expect.stringContaining('Encryption not available')

View File

@@ -1,18 +1,53 @@
import { generateApiKey } from '../src/utils/auth-utils'; import { generateApiKey } from '../src/utils/auth-utils';
import { encryptApiKey, decryptApiKey } from '../src/utils/encryption-utils';
import { DEFAULT_SETTINGS } from '../src/types/settings-types'; import { DEFAULT_SETTINGS } from '../src/types/settings-types';
// Mock electron // Mock safeStorage implementation
jest.mock('electron', () => ({ const mockSafeStorage = {
safeStorage: { isEncryptionAvailable: jest.fn(() => true),
isEncryptionAvailable: jest.fn(() => true), encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)),
encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)), decryptString: jest.fn((buffer: Buffer) => buffer.toString().replace('encrypted:', ''))
decryptString: jest.fn((buffer: Buffer) => { };
const str = buffer.toString();
return str.replace('encrypted:', ''); // Setup window.require mock
}) const mockWindowRequire = jest.fn((module: string) => {
if (module === 'electron') {
return { safeStorage: mockSafeStorage };
} }
})); throw new Error(`Module not found: ${module}`);
});
// Create mock window object for Node environment
const mockWindow: Window & { require?: unknown } = {
require: mockWindowRequire
} as unknown as Window & { require?: unknown };
// Store original global window
const originalWindow = (globalThis as unknown as { window?: unknown }).window;
// Set up window.require before tests run
beforeAll(() => {
(globalThis as unknown as { window: typeof mockWindow }).window = mockWindow;
});
// Clean up after all tests
afterAll(() => {
if (originalWindow === undefined) {
delete (globalThis as unknown as { window?: unknown }).window;
} else {
(globalThis as unknown as { window: typeof originalWindow }).window = originalWindow;
}
});
// Import after mock is set up
let encryptApiKey: typeof import('../src/utils/encryption-utils').encryptApiKey;
let decryptApiKey: typeof import('../src/utils/encryption-utils').decryptApiKey;
beforeAll(() => {
jest.resetModules();
const encryptionUtils = require('../src/utils/encryption-utils');
encryptApiKey = encryptionUtils.encryptApiKey;
decryptApiKey = encryptionUtils.decryptApiKey;
});
describe('Settings Migration', () => { describe('Settings Migration', () => {
describe('API key initialization', () => { describe('API key initialization', () => {

View File

@@ -21,7 +21,7 @@ jest.mock('../src/utils/path-utils', () => ({
// Mock LinkUtils for link validation tests // Mock LinkUtils for link validation tests
jest.mock('../src/utils/link-utils', () => ({ jest.mock('../src/utils/link-utils', () => ({
LinkUtils: { LinkUtils: {
validateLinks: jest.fn().mockResolvedValue({ validateLinks: jest.fn().mockReturnValue({
valid: [], valid: [],
brokenNotes: [], brokenNotes: [],
brokenHeadings: [], brokenHeadings: [],

View File

@@ -2,5 +2,7 @@
"1.0.0": "0.15.0", "1.0.0": "0.15.0",
"1.0.1": "0.15.0", "1.0.1": "0.15.0",
"1.1.0": "0.15.0", "1.1.0": "0.15.0",
"1.1.1": "0.15.0" "1.1.1": "0.15.0",
"1.1.2": "0.15.0",
"1.1.3": "0.15.0"
} }