Add secure credential storage (GNOME Keyring + file fallback), LLM provider config in ~/.mcpctl/config.json, interactive setup wizard (mcpctl config setup), and wire configured provider into mcplocal for smart pagination summaries. - Secret store: SecretStore interface, GnomeKeyringStore, FileSecretStore - Config schema: LlmConfigSchema with provider/model/url/binaryPath - Setup wizard: arrow-key provider/model selection, dynamic model fetch - Provider factory: creates ProviderRegistry from config + secrets - Status: shows LLM line with hint when not configured - 572 tests passing across all packages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
94 lines
3.3 KiB
TypeScript
94 lines
3.3 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtempSync, rmSync, statSync, existsSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { FileSecretStore } from '../../src/secrets/file-store.js';
|
|
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-secrets-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('FileSecretStore', () => {
|
|
it('returns null for missing key', async () => {
|
|
const store = new FileSecretStore({ configDir: tempDir });
|
|
expect(await store.get('nonexistent')).toBeNull();
|
|
});
|
|
|
|
it('stores and retrieves a secret', async () => {
|
|
const store = new FileSecretStore({ configDir: tempDir });
|
|
await store.set('api-key', 'sk-12345');
|
|
expect(await store.get('api-key')).toBe('sk-12345');
|
|
});
|
|
|
|
it('overwrites existing values', async () => {
|
|
const store = new FileSecretStore({ configDir: tempDir });
|
|
await store.set('api-key', 'old-value');
|
|
await store.set('api-key', 'new-value');
|
|
expect(await store.get('api-key')).toBe('new-value');
|
|
});
|
|
|
|
it('stores multiple keys', async () => {
|
|
const store = new FileSecretStore({ configDir: tempDir });
|
|
await store.set('key-a', 'value-a');
|
|
await store.set('key-b', 'value-b');
|
|
expect(await store.get('key-a')).toBe('value-a');
|
|
expect(await store.get('key-b')).toBe('value-b');
|
|
});
|
|
|
|
it('deletes a key', async () => {
|
|
const store = new FileSecretStore({ configDir: tempDir });
|
|
await store.set('api-key', 'sk-12345');
|
|
expect(await store.delete('api-key')).toBe(true);
|
|
expect(await store.get('api-key')).toBeNull();
|
|
});
|
|
|
|
it('returns false when deleting nonexistent key', async () => {
|
|
const store = new FileSecretStore({ configDir: tempDir });
|
|
expect(await store.delete('nonexistent')).toBe(false);
|
|
});
|
|
|
|
it('sets 0600 permissions on secrets file', async () => {
|
|
const store = new FileSecretStore({ configDir: tempDir });
|
|
await store.set('api-key', 'sk-12345');
|
|
const stat = statSync(join(tempDir, 'secrets'));
|
|
expect(stat.mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
it('creates config dir if missing', async () => {
|
|
const nested = join(tempDir, 'sub', 'dir');
|
|
const store = new FileSecretStore({ configDir: nested });
|
|
await store.set('api-key', 'sk-12345');
|
|
expect(existsSync(join(nested, 'secrets'))).toBe(true);
|
|
});
|
|
|
|
it('recovers from corrupted JSON', async () => {
|
|
writeFileSync(join(tempDir, 'secrets'), 'NOT JSON!!!', 'utf-8');
|
|
const store = new FileSecretStore({ configDir: tempDir });
|
|
// Should not throw, returns null for any key
|
|
expect(await store.get('api-key')).toBeNull();
|
|
// Should be able to write over corrupted file
|
|
await store.set('api-key', 'fresh-value');
|
|
expect(await store.get('api-key')).toBe('fresh-value');
|
|
});
|
|
|
|
it('reports file backend', () => {
|
|
const store = new FileSecretStore({ configDir: tempDir });
|
|
expect(store.backend()).toBe('file');
|
|
});
|
|
|
|
it('preserves other keys on delete', async () => {
|
|
const store = new FileSecretStore({ configDir: tempDir });
|
|
await store.set('key-a', 'value-a');
|
|
await store.set('key-b', 'value-b');
|
|
await store.delete('key-a');
|
|
expect(await store.get('key-a')).toBeNull();
|
|
expect(await store.get('key-b')).toBe('value-b');
|
|
});
|
|
});
|