/**
* Tests for configure command utilities
*
* @package WP_Navigator_MCP
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// =============================================================================
// Helper Functions (extracted for testing)
// =============================================================================
/**
* Parse existing .wpnav.env file
*/
function parseWpnavEnv(content: string): Record<string, string> {
const result: Record<string, string> = {};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1).trim();
// Remove surrounding quotes
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[key] = value;
}
}
return result;
}
/**
* Generate .wpnav.env content
*/
function generateWpnavEnvContent(
siteUrl: string,
username: string,
password: string
): string {
const timestamp = new Date().toISOString();
return `# WP Navigator Connection Settings
# Generated by wpnav configure on ${timestamp}
#
# WARNING: This file contains sensitive credentials.
# Add .wpnav.env to your .gitignore file!
# WordPress Site URL (without trailing slash)
WP_BASE_URL=${siteUrl}
# REST API endpoint (usually <site>/wp-json)
WP_REST_API=${siteUrl}/wp-json
# WP Navigator API base
WPNAV_BASE=${siteUrl}/wp-json/wpnav/v1
# Introspect endpoint for plugin discovery
WPNAV_INTROSPECT=${siteUrl}/wp-json/wpnav/v1/introspect
# Application Password credentials
# Generate at: ${siteUrl}/wp-admin/profile.php#application-passwords
WP_APP_USER=${username}
WP_APP_PASS=${password}
`;
}
/**
* Validate WordPress URL format
*/
function validateWordPressUrl(url: string): string | null {
if (!url) return 'URL is required';
// Must start with http:// or https://
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return 'URL must start with http:// or https://';
}
// Basic URL validation
try {
const parsed = new URL(url);
if (!parsed.hostname) return 'Invalid URL format';
} catch {
return 'Invalid URL format';
}
// No trailing slash
if (url.endsWith('/')) {
return 'URL should not end with a slash';
}
return null;
}
/**
* Validate username format
*/
function validateUsername(username: string): string | null {
if (!username) return 'Username is required';
if (username.length < 2) return 'Username must be at least 2 characters';
return null;
}
/**
* Validate Application Password format
*/
function validatePassword(password: string): string | null {
if (!password) return 'Password is required';
// WordPress app passwords are typically 24 characters with spaces (4 groups of 6)
// But they can also be entered without spaces (24 chars)
const cleaned = password.replace(/\s/g, '');
if (cleaned.length < 16) {
return 'Application password seems too short. Generate one at WordPress Admin → Users → Profile → Application Passwords';
}
return null;
}
/**
* Mask password for display (show first 4 chars, rest as dots)
*/
function maskPassword(password: string): string {
if (password.length <= 4) return '****';
return password.slice(0, 4) + '•'.repeat(Math.min(password.length - 4, 16));
}
/**
* Write .wpnav.env file with atomic write (temp file + rename)
*/
function writeWpnavEnvAtomic(filePath: string, content: string): void {
const tempPath = `${filePath}.${process.pid}.tmp`;
try {
// Write to temp file first
fs.writeFileSync(tempPath, content, { encoding: 'utf8', mode: 0o600 });
// Atomic rename
fs.renameSync(tempPath, filePath);
} catch (err) {
// Clean up temp file if it exists
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch {
// Ignore cleanup errors
}
throw err;
}
}
// =============================================================================
// Tests
// =============================================================================
describe('parseWpnavEnv', () => {
it('should parse simple key=value pairs', () => {
const content = `WP_BASE_URL=https://example.com
WP_APP_USER=admin`;
const result = parseWpnavEnv(content);
expect(result.WP_BASE_URL).toBe('https://example.com');
expect(result.WP_APP_USER).toBe('admin');
});
it('should skip comments', () => {
const content = `# This is a comment
WP_BASE_URL=https://example.com
# Another comment
WP_APP_USER=admin`;
const result = parseWpnavEnv(content);
expect(Object.keys(result)).toHaveLength(2);
expect(result.WP_BASE_URL).toBe('https://example.com');
});
it('should skip empty lines', () => {
const content = `WP_BASE_URL=https://example.com
WP_APP_USER=admin
`;
const result = parseWpnavEnv(content);
expect(Object.keys(result)).toHaveLength(2);
});
it('should remove surrounding quotes from values', () => {
const content = `WP_BASE_URL="https://example.com"
WP_APP_PASS='xxxx xxxx xxxx xxxx'`;
const result = parseWpnavEnv(content);
expect(result.WP_BASE_URL).toBe('https://example.com');
expect(result.WP_APP_PASS).toBe('xxxx xxxx xxxx xxxx');
});
it('should handle values with equals signs', () => {
const content = `SOME_VAR=value=with=equals`;
const result = parseWpnavEnv(content);
expect(result.SOME_VAR).toBe('value=with=equals');
});
it('should handle empty values', () => {
const content = `EMPTY_VAR=
ANOTHER_VAR=value`;
const result = parseWpnavEnv(content);
expect(result.EMPTY_VAR).toBe('');
expect(result.ANOTHER_VAR).toBe('value');
});
});
describe('generateWpnavEnvContent', () => {
it('should generate valid env file content', () => {
const content = generateWpnavEnvContent(
'https://example.com',
'admin',
'xxxx xxxx xxxx xxxx'
);
expect(content).toContain('WP_BASE_URL=https://example.com');
expect(content).toContain('WP_REST_API=https://example.com/wp-json');
expect(content).toContain('WPNAV_BASE=https://example.com/wp-json/wpnav/v1');
expect(content).toContain('WPNAV_INTROSPECT=https://example.com/wp-json/wpnav/v1/introspect');
expect(content).toContain('WP_APP_USER=admin');
expect(content).toContain('WP_APP_PASS=xxxx xxxx xxxx xxxx');
});
it('should include warning about gitignore', () => {
const content = generateWpnavEnvContent('https://example.com', 'admin', 'pass');
expect(content).toContain('.gitignore');
expect(content).toContain('WARNING');
});
it('should include timestamp', () => {
const content = generateWpnavEnvContent('https://example.com', 'admin', 'pass');
expect(content).toContain('Generated by wpnav configure on');
});
it('should include application password help URL', () => {
const content = generateWpnavEnvContent('https://example.com', 'admin', 'pass');
expect(content).toContain('https://example.com/wp-admin/profile.php#application-passwords');
});
it('generated content should be parseable', () => {
const content = generateWpnavEnvContent(
'https://example.com',
'testuser',
'test password here'
);
const parsed = parseWpnavEnv(content);
expect(parsed.WP_BASE_URL).toBe('https://example.com');
expect(parsed.WP_APP_USER).toBe('testuser');
expect(parsed.WP_APP_PASS).toBe('test password here');
});
});
describe('validateWordPressUrl', () => {
it('should accept valid https URLs', () => {
expect(validateWordPressUrl('https://example.com')).toBeNull();
expect(validateWordPressUrl('https://sub.example.com')).toBeNull();
expect(validateWordPressUrl('https://example.com:8080')).toBeNull();
expect(validateWordPressUrl('https://example.com/wordpress')).toBeNull();
});
it('should accept valid http URLs', () => {
expect(validateWordPressUrl('http://localhost')).toBeNull();
expect(validateWordPressUrl('http://localhost:8080')).toBeNull();
expect(validateWordPressUrl('http://192.168.1.100')).toBeNull();
});
it('should reject empty URL', () => {
expect(validateWordPressUrl('')).toBe('URL is required');
});
it('should reject URLs without protocol', () => {
expect(validateWordPressUrl('example.com')).toBe('URL must start with http:// or https://');
});
it('should reject URLs with trailing slash', () => {
expect(validateWordPressUrl('https://example.com/')).toBe('URL should not end with a slash');
});
it('should reject invalid URL formats', () => {
expect(validateWordPressUrl('https://')).toBe('Invalid URL format');
expect(validateWordPressUrl('not a url')).toBe('URL must start with http:// or https://');
});
});
describe('validateUsername', () => {
it('should accept valid usernames', () => {
expect(validateUsername('admin')).toBeNull();
expect(validateUsername('john_doe')).toBeNull();
expect(validateUsername('ab')).toBeNull(); // minimum 2 chars
});
it('should reject empty username', () => {
expect(validateUsername('')).toBe('Username is required');
});
it('should reject too short username', () => {
expect(validateUsername('a')).toBe('Username must be at least 2 characters');
});
});
describe('validatePassword', () => {
it('should accept valid application passwords with spaces', () => {
// 24 characters with spaces (4 groups of 6 chars)
expect(validatePassword('abcdef ghijkl mnopqr stuvwx')).toBeNull();
});
it('should accept valid application passwords without spaces', () => {
// 24 characters without spaces
expect(validatePassword('abcdefghijklmnopqrstuvwx')).toBeNull();
});
it('should accept passwords 16 chars or longer', () => {
expect(validatePassword('1234567890123456')).toBeNull();
});
it('should reject empty password', () => {
expect(validatePassword('')).toBe('Password is required');
});
it('should reject too short passwords', () => {
const result = validatePassword('short');
expect(result).toContain('too short');
});
});
describe('maskPassword', () => {
it('should show first 4 chars and mask the rest', () => {
const result = maskPassword('abcdefghijkl');
expect(result.startsWith('abcd')).toBe(true);
expect(result).toContain('•');
});
it('should handle short passwords', () => {
expect(maskPassword('abc')).toBe('****');
expect(maskPassword('abcd')).toBe('****');
});
it('should limit mask length to 16 dots', () => {
const result = maskPassword('a'.repeat(100));
// First 4 chars + 16 dots
expect(result.length).toBe(20);
});
});
describe('writeWpnavEnvAtomic', () => {
let testDir: string;
let testFile: string;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wpnav-test-'));
testFile = path.join(testDir, '.wpnav.env');
});
afterEach(() => {
// Clean up
try {
fs.rmSync(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
it('should write file with correct content', () => {
const content = 'WP_BASE_URL=https://example.com\n';
writeWpnavEnvAtomic(testFile, content);
const written = fs.readFileSync(testFile, 'utf8');
expect(written).toBe(content);
});
it('should not leave temp files on success', () => {
const content = 'WP_BASE_URL=https://example.com\n';
writeWpnavEnvAtomic(testFile, content);
const files = fs.readdirSync(testDir);
expect(files).toHaveLength(1);
expect(files[0]).toBe('.wpnav.env');
});
it('should overwrite existing file', () => {
// Write initial content
fs.writeFileSync(testFile, 'OLD_CONTENT=value\n');
// Overwrite
const newContent = 'NEW_CONTENT=value\n';
writeWpnavEnvAtomic(testFile, newContent);
const written = fs.readFileSync(testFile, 'utf8');
expect(written).toBe(newContent);
});
it('should set restrictive file permissions', () => {
const content = 'WP_BASE_URL=https://example.com\n';
writeWpnavEnvAtomic(testFile, content);
const stats = fs.statSync(testFile);
// Check that file is readable/writable by owner only (0o600 = 384 in decimal)
// On some systems, umask affects this, so just check it's not world-readable
const mode = stats.mode & 0o777;
expect((mode & 0o077)).toBe(0); // No group or other permissions
});
});
describe('roundtrip integration', () => {
let testDir: string;
let testFile: string;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wpnav-test-'));
testFile = path.join(testDir, '.wpnav.env');
});
afterEach(() => {
try {
fs.rmSync(testDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
it('should roundtrip generate -> write -> parse', () => {
const siteUrl = 'https://mysite.example.com';
const username = 'testadmin';
const password = 'xxxx yyyy zzzz wwww';
// Generate content
const content = generateWpnavEnvContent(siteUrl, username, password);
// Write to file
writeWpnavEnvAtomic(testFile, content);
// Read and parse
const readContent = fs.readFileSync(testFile, 'utf8');
const parsed = parseWpnavEnv(readContent);
// Verify
expect(parsed.WP_BASE_URL).toBe(siteUrl);
expect(parsed.WP_APP_USER).toBe(username);
expect(parsed.WP_APP_PASS).toBe(password);
expect(parsed.WP_REST_API).toBe(`${siteUrl}/wp-json`);
});
});