diff --git a/packages/atxp/src/commands/paas/secrets.test.ts b/packages/atxp/src/commands/paas/secrets.test.ts new file mode 100644 index 0000000..2585244 --- /dev/null +++ b/packages/atxp/src/commands/paas/secrets.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the call-tool module +vi.mock('../../call-tool.js', () => ({ + callTool: vi.fn(), +})); + +import { callTool } from '../../call-tool.js'; +import { + secretsSetCommand, + secretsListCommand, + secretsDeleteCommand, + isValidSecretKey, + parseKeyValue, +} from './secrets.js'; + +describe('parseKeyValue', () => { + it('should parse valid KEY=VALUE format', () => { + const result = parseKeyValue('API_KEY=sk-123456'); + expect(result).toEqual({ key: 'API_KEY', value: 'sk-123456' }); + }); + + it('should handle empty value', () => { + const result = parseKeyValue('EMPTY_KEY='); + expect(result).toEqual({ key: 'EMPTY_KEY', value: '' }); + }); + + it('should handle value with equals sign', () => { + const result = parseKeyValue('CONNECTION=host=localhost;port=5432'); + expect(result).toEqual({ key: 'CONNECTION', value: 'host=localhost;port=5432' }); + }); + + it('should handle value with special characters', () => { + const result = parseKeyValue('SECRET=abc!@#$%^&*()'); + expect(result).toEqual({ key: 'SECRET', value: 'abc!@#$%^&*()' }); + }); + + it('should handle value with spaces', () => { + const result = parseKeyValue('MESSAGE=hello world'); + expect(result).toEqual({ key: 'MESSAGE', value: 'hello world' }); + }); + + it('should return null when no equals sign', () => { + const result = parseKeyValue('NO_EQUALS_SIGN'); + expect(result).toBeNull(); + }); + + it('should return null for empty string', () => { + const result = parseKeyValue(''); + expect(result).toBeNull(); + }); + + it('should handle key starting with equals', () => { + const result = parseKeyValue('=value'); + expect(result).toEqual({ key: '', value: 'value' }); + }); + + it('should handle long values', () => { + const longValue = 'a'.repeat(1000); + const result = parseKeyValue(`LONG_KEY=${longValue}`); + expect(result).toEqual({ key: 'LONG_KEY', value: longValue }); + }); +}); + +describe('isValidSecretKey', () => { + describe('valid keys', () => { + it('should accept simple uppercase key', () => { + expect(isValidSecretKey('API_KEY')).toBe(true); + }); + + it('should accept key with numbers', () => { + expect(isValidSecretKey('API_KEY_V2')).toBe(true); + }); + + it('should accept single character key', () => { + expect(isValidSecretKey('A')).toBe(true); + }); + + it('should accept key with trailing numbers', () => { + expect(isValidSecretKey('SECRET123')).toBe(true); + }); + + it('should accept key with multiple underscores', () => { + expect(isValidSecretKey('MY_SUPER_SECRET_KEY')).toBe(true); + }); + + it('should accept key starting with underscore followed by uppercase', () => { + // Note: based on the regex /^[A-Z][A-Z0-9_]*$/, this should be invalid + // since it requires starting with uppercase letter + expect(isValidSecretKey('_PRIVATE')).toBe(false); + }); + }); + + describe('invalid keys', () => { + it('should reject lowercase key', () => { + expect(isValidSecretKey('api_key')).toBe(false); + }); + + it('should reject mixed case key', () => { + expect(isValidSecretKey('Api_Key')).toBe(false); + }); + + it('should reject key starting with number', () => { + expect(isValidSecretKey('123_KEY')).toBe(false); + }); + + it('should reject key with hyphen', () => { + expect(isValidSecretKey('API-KEY')).toBe(false); + }); + + it('should reject key with spaces', () => { + expect(isValidSecretKey('API KEY')).toBe(false); + }); + + it('should reject empty string', () => { + expect(isValidSecretKey('')).toBe(false); + }); + + it('should reject key with special characters', () => { + expect(isValidSecretKey('API@KEY')).toBe(false); + }); + + it('should reject key starting with underscore', () => { + expect(isValidSecretKey('_API_KEY')).toBe(false); + }); + + it('should reject key with lowercase letters', () => { + expect(isValidSecretKey('APIkey')).toBe(false); + }); + }); +}); + +describe('Secrets Commands', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + describe('secretsSetCommand', () => { + it('should set a secret with valid key and value', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await secretsSetCommand('my-worker', 'API_KEY=sk-123456'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'set_secret', { + worker_name: 'my-worker', + key: 'API_KEY', + value: 'sk-123456', + }); + }); + + it('should set a secret with value containing equals sign', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await secretsSetCommand('my-worker', 'DATABASE_URL=postgres://user:pass@host/db?ssl=true'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'set_secret', { + worker_name: 'my-worker', + key: 'DATABASE_URL', + value: 'postgres://user:pass@host/db?ssl=true', + }); + }); + + it('should exit with error for invalid format (no equals)', async () => { + await expect(secretsSetCommand('my-worker', 'INVALID_FORMAT')).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid format') + ); + }); + + it('should exit with error for invalid key (lowercase)', async () => { + await expect(secretsSetCommand('my-worker', 'api_key=value')).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('UPPER_SNAKE_CASE') + ); + }); + + it('should exit with error for invalid key (starts with number)', async () => { + await expect(secretsSetCommand('my-worker', '123KEY=value')).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('UPPER_SNAKE_CASE') + ); + }); + + it('should exit with error for invalid key (contains hyphen)', async () => { + await expect(secretsSetCommand('my-worker', 'API-KEY=value')).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('UPPER_SNAKE_CASE') + ); + }); + + it('should exit with error for empty value', async () => { + await expect(secretsSetCommand('my-worker', 'API_KEY=')).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('cannot be empty') + ); + }); + + it('should log success result', async () => { + const mockResult = '{"success": true, "name": "API_KEY"}'; + vi.mocked(callTool).mockResolvedValue(mockResult); + + await secretsSetCommand('my-worker', 'API_KEY=sk-123'); + + expect(console.log).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('secretsListCommand', () => { + it('should list secrets for a worker', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true, "secrets": []}'); + + await secretsListCommand('my-worker'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'list_secrets', { + worker_name: 'my-worker', + }); + }); + + it('should log result', async () => { + const mockResult = '{"success": true, "secrets": [{"name": "API_KEY"}]}'; + vi.mocked(callTool).mockResolvedValue(mockResult); + + await secretsListCommand('my-worker'); + + expect(console.log).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('secretsDeleteCommand', () => { + it('should delete a secret with valid key', async () => { + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await secretsDeleteCommand('my-worker', 'API_KEY'); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'delete_secret', { + worker_name: 'my-worker', + key: 'API_KEY', + }); + }); + + it('should exit with error for invalid key (lowercase)', async () => { + await expect(secretsDeleteCommand('my-worker', 'api_key')).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('UPPER_SNAKE_CASE') + ); + }); + + it('should exit with error for invalid key (starts with number)', async () => { + await expect(secretsDeleteCommand('my-worker', '123KEY')).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('UPPER_SNAKE_CASE') + ); + }); + + it('should exit with error for invalid key (contains hyphen)', async () => { + await expect(secretsDeleteCommand('my-worker', 'API-KEY')).rejects.toThrow( + 'process.exit called' + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('UPPER_SNAKE_CASE') + ); + }); + + it('should log success result', async () => { + const mockResult = '{"success": true}'; + vi.mocked(callTool).mockResolvedValue(mockResult); + + await secretsDeleteCommand('my-worker', 'API_KEY'); + + expect(console.log).toHaveBeenCalledWith(mockResult); + }); + }); +}); diff --git a/packages/atxp/src/commands/paas/secrets.ts b/packages/atxp/src/commands/paas/secrets.ts index 27d96b2..8df6326 100644 --- a/packages/atxp/src/commands/paas/secrets.ts +++ b/packages/atxp/src/commands/paas/secrets.ts @@ -6,14 +6,14 @@ const SERVER = 'paas.mcp.atxp.ai'; /** * Validate that a secret key follows UPPER_SNAKE_CASE convention */ -function isValidSecretKey(key: string): boolean { +export function isValidSecretKey(key: string): boolean { return /^[A-Z][A-Z0-9_]*$/.test(key); } /** * Parse KEY=VALUE format into key and value */ -function parseKeyValue(input: string): { key: string; value: string } | null { +export function parseKeyValue(input: string): { key: string; value: string } | null { const eqIndex = input.indexOf('='); if (eqIndex === -1) { return null; diff --git a/packages/atxp/src/commands/paas/worker.test.ts b/packages/atxp/src/commands/paas/worker.test.ts index e6ea044..8d984ec 100644 --- a/packages/atxp/src/commands/paas/worker.test.ts +++ b/packages/atxp/src/commands/paas/worker.test.ts @@ -20,8 +20,312 @@ import { workerListCommand, workerLogsCommand, workerDeleteCommand, + parseEnvArg, + parseEnvFile, + validateEnvVarName, + BASE_RESERVED_ENV_NAMES, + getReservedEnvNames, + SENSITIVE_PATTERNS, } from './worker.js'; +describe('parseEnvArg', () => { + it('should parse valid KEY=VALUE format', () => { + const result = parseEnvArg('MY_VAR=my_value'); + expect(result).toEqual({ key: 'MY_VAR', value: 'my_value' }); + }); + + it('should handle empty value', () => { + const result = parseEnvArg('MY_VAR='); + expect(result).toEqual({ key: 'MY_VAR', value: '' }); + }); + + it('should handle value with equals sign', () => { + const result = parseEnvArg('MY_VAR=value=with=equals'); + expect(result).toEqual({ key: 'MY_VAR', value: 'value=with=equals' }); + }); + + it('should handle value with special characters', () => { + const result = parseEnvArg('MY_VAR=hello world!@#$%'); + expect(result).toEqual({ key: 'MY_VAR', value: 'hello world!@#$%' }); + }); + + it('should return null when no equals sign', () => { + const result = parseEnvArg('MY_VAR_NO_VALUE'); + expect(result).toBeNull(); + }); + + it('should return null for empty string', () => { + const result = parseEnvArg(''); + expect(result).toBeNull(); + }); + + it('should handle key starting with equals', () => { + const result = parseEnvArg('=value'); + expect(result).toEqual({ key: '', value: 'value' }); + }); + + it('should handle URL as value', () => { + const result = parseEnvArg('DATABASE_URL=postgres://user:pass@host:5432/db'); + expect(result).toEqual({ key: 'DATABASE_URL', value: 'postgres://user:pass@host:5432/db' }); + }); +}); + +describe('parseEnvFile', () => { + it('should parse simple KEY=VALUE lines', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('FOO=bar\nBAZ=qux'); + + const result = parseEnvFile('./test.env'); + + expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); + + it('should skip comment lines', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('# This is a comment\nFOO=bar\n# Another comment\nBAZ=qux'); + + const result = parseEnvFile('./test.env'); + + expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); + + it('should skip empty lines', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('FOO=bar\n\n\nBAZ=qux\n'); + + const result = parseEnvFile('./test.env'); + + expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); + + it('should handle double-quoted values', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('FOO="bar with spaces"\nBAZ="value"'); + + const result = parseEnvFile('./test.env'); + + expect(result).toEqual({ FOO: 'bar with spaces', BAZ: 'value' }); + }); + + it('should handle single-quoted values', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("FOO='bar with spaces'\nBAZ='value'"); + + const result = parseEnvFile('./test.env'); + + expect(result).toEqual({ FOO: 'bar with spaces', BAZ: 'value' }); + }); + + it('should handle values with equals signs', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('CONNECTION=host=localhost;port=5432'); + + const result = parseEnvFile('./test.env'); + + expect(result).toEqual({ CONNECTION: 'host=localhost;port=5432' }); + }); + + it('should skip lines without equals sign', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('FOO=bar\nINVALID_LINE\nBAZ=qux'); + + const result = parseEnvFile('./test.env'); + + expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); + + it('should trim whitespace around keys and values', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(' FOO = bar \n BAZ = qux '); + + const result = parseEnvFile('./test.env'); + + expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); + + it('should throw error when file does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + expect(() => parseEnvFile('./nonexistent.env')).toThrow('Env file not found'); + }); + + it('should handle empty file', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(''); + + const result = parseEnvFile('./empty.env'); + + expect(result).toEqual({}); + }); + + it('should handle file with only comments', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('# comment 1\n# comment 2'); + + const result = parseEnvFile('./comments.env'); + + expect(result).toEqual({}); + }); + + it('should handle empty values', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('EMPTY=\nANOTHER=""'); + + const result = parseEnvFile('./test.env'); + + expect(result).toEqual({ EMPTY: '', ANOTHER: '' }); + }); +}); + +describe('validateEnvVarName', () => { + const reservedNames = BASE_RESERVED_ENV_NAMES; + + describe('valid identifier tests', () => { + it('should accept valid alphanumeric name', () => { + const result = validateEnvVarName('MY_VAR', reservedNames); + expect(result).toEqual({ valid: true }); + }); + + it('should accept name starting with letter', () => { + const result = validateEnvVarName('A', reservedNames); + expect(result).toEqual({ valid: true }); + }); + + it('should accept name starting with underscore', () => { + const result = validateEnvVarName('_PRIVATE_VAR', reservedNames); + expect(result).toEqual({ valid: true }); + }); + + it('should accept name with numbers', () => { + const result = validateEnvVarName('VAR_123', reservedNames); + expect(result).toEqual({ valid: true }); + }); + + it('should accept lowercase name', () => { + const result = validateEnvVarName('my_var', reservedNames); + expect(result).toEqual({ valid: true }); + }); + + it('should accept mixed case name', () => { + const result = validateEnvVarName('MyVar_Test', reservedNames); + expect(result).toEqual({ valid: true }); + }); + }); + + describe('invalid identifier tests', () => { + it('should reject name starting with number', () => { + const result = validateEnvVarName('123VAR', reservedNames); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a valid identifier'); + }); + + it('should reject name with hyphen', () => { + const result = validateEnvVarName('MY-VAR', reservedNames); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a valid identifier'); + }); + + it('should reject name with spaces', () => { + const result = validateEnvVarName('MY VAR', reservedNames); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a valid identifier'); + }); + + it('should reject name with special characters', () => { + const result = validateEnvVarName('MY@VAR', reservedNames); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a valid identifier'); + }); + + it('should reject empty string', () => { + const result = validateEnvVarName('', reservedNames); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a valid identifier'); + }); + }); + + describe('reserved name tests', () => { + it('should reject DB as reserved name', () => { + const result = validateEnvVarName('DB', reservedNames); + expect(result.valid).toBe(false); + expect(result.error).toContain('Reserved env var name'); + expect(result.error).toContain('conflicts with existing bindings'); + }); + + it('should reject BUCKET as reserved name', () => { + const result = validateEnvVarName('BUCKET', reservedNames); + expect(result.valid).toBe(false); + expect(result.error).toContain('Reserved env var name'); + }); + + it('should reject USER_NAMESPACE as reserved name', () => { + const result = validateEnvVarName('USER_NAMESPACE', reservedNames); + expect(result.valid).toBe(false); + expect(result.error).toContain('Reserved env var name'); + }); + + it('should reject lowercase reserved name (case-insensitive)', () => { + const result = validateEnvVarName('db', reservedNames); + expect(result.valid).toBe(false); + expect(result.error).toContain('Reserved env var name'); + }); + }); +}); + +describe('getReservedEnvNames', () => { + it('should return base reserved names plus ANALYTICS by default', () => { + const result = getReservedEnvNames(); + expect(result).toEqual(['DB', 'BUCKET', 'USER_NAMESPACE', 'ANALYTICS']); + }); + + it('should return base reserved names plus custom binding name', () => { + const result = getReservedEnvNames('MY_STATS'); + expect(result).toEqual(['DB', 'BUCKET', 'USER_NAMESPACE', 'MY_STATS']); + }); + + it('should uppercase the custom binding name', () => { + const result = getReservedEnvNames('my_stats'); + expect(result).toEqual(['DB', 'BUCKET', 'USER_NAMESPACE', 'MY_STATS']); + }); +}); + +describe('SENSITIVE_PATTERNS', () => { + it('should detect SECRET in variable name', () => { + const hasSensitive = SENSITIVE_PATTERNS.some(p => p.test('MY_SECRET')); + expect(hasSensitive).toBe(true); + }); + + it('should detect PASSWORD in variable name', () => { + const hasSensitive = SENSITIVE_PATTERNS.some(p => p.test('DB_PASSWORD')); + expect(hasSensitive).toBe(true); + }); + + it('should detect KEY in variable name', () => { + const hasSensitive = SENSITIVE_PATTERNS.some(p => p.test('API_KEY')); + expect(hasSensitive).toBe(true); + }); + + it('should detect TOKEN in variable name', () => { + const hasSensitive = SENSITIVE_PATTERNS.some(p => p.test('AUTH_TOKEN')); + expect(hasSensitive).toBe(true); + }); + + it('should detect CREDENTIAL in variable name', () => { + const hasSensitive = SENSITIVE_PATTERNS.some(p => p.test('AWS_CREDENTIAL')); + expect(hasSensitive).toBe(true); + }); + + it('should be case insensitive', () => { + const hasSensitive = SENSITIVE_PATTERNS.some(p => p.test('api_key')); + expect(hasSensitive).toBe(true); + }); + + it('should not match non-sensitive variable names', () => { + const hasSensitive = SENSITIVE_PATTERNS.some(p => p.test('DATABASE_URL')); + expect(hasSensitive).toBe(false); + }); +}); + describe('Worker Commands', () => { beforeEach(() => { vi.clearAllMocks(); @@ -182,6 +486,157 @@ describe('Worker Commands', () => { ); expect(console.error).toHaveBeenCalled(); }); + + it('should pass env vars from --env flag', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(mockCode); + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await workerDeployCommand('my-worker', { + code: './worker.js', + env: ['FOO=bar', 'BAZ=qux'], + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'deploy_worker', { + name: 'my-worker', + code: mockCode, + env_vars: { FOO: 'bar', BAZ: 'qux' }, + }); + }); + + it('should pass env vars from --env-file flag', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync) + .mockReturnValueOnce(mockCode) // code file + .mockReturnValueOnce('FILE_VAR=file_value\nANOTHER=another_value'); // env file + + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await workerDeployCommand('my-worker', { + code: './worker.js', + envFile: './test.env', + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'deploy_worker', { + name: 'my-worker', + code: mockCode, + env_vars: { FILE_VAR: 'file_value', ANOTHER: 'another_value' }, + }); + }); + + it('should give --env flag precedence over --env-file', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync) + .mockReturnValueOnce(mockCode) // code file + .mockReturnValueOnce('SHARED_VAR=from_file\nFILE_ONLY=file_value'); // env file + + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await workerDeployCommand('my-worker', { + code: './worker.js', + envFile: './test.env', + env: ['SHARED_VAR=from_cli', 'CLI_ONLY=cli_value'], + }); + + expect(callTool).toHaveBeenCalledWith('paas.mcp.atxp.ai', 'deploy_worker', { + name: 'my-worker', + code: mockCode, + env_vars: { + SHARED_VAR: 'from_cli', // CLI overrides file + FILE_ONLY: 'file_value', // File-only var preserved + CLI_ONLY: 'cli_value', // CLI-only var preserved + }, + }); + }); + + it('should warn about sensitive env var names', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(mockCode); + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await workerDeployCommand('my-worker', { + code: './worker.js', + env: ['API_KEY=sk-123', 'DATABASE_URL=postgres://localhost'], + }); + + // Should have warned about API_KEY (contains KEY) + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('may contain sensitive data') + ); + }); + + it('should warn about multiple sensitive env var names', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(mockCode); + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await workerDeployCommand('my-worker', { + code: './worker.js', + env: ['API_KEY=sk-123', 'MY_SECRET=secret', 'AUTH_TOKEN=token123'], + }); + + // Should have warned about sensitive vars + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('may contain sensitive data') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Consider using Cloudflare Secrets') + ); + }); + + it('should not warn when no sensitive env var names', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(mockCode); + vi.mocked(callTool).mockResolvedValue('{"success": true}'); + + await workerDeployCommand('my-worker', { + code: './worker.js', + env: ['DATABASE_URL=postgres://localhost', 'APP_NAME=myapp'], + }); + + // Should NOT have warned about sensitive vars + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('may contain sensitive data') + ); + }); + + it('should reject invalid env var format (no equals sign)', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(mockCode); + + await expect( + workerDeployCommand('my-worker', { + code: './worker.js', + env: ['INVALID_NO_EQUALS'], + }) + ).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid env var format') + ); + }); + + it('should reject invalid env var name', async () => { + const mockCode = 'export default { fetch() { return new Response("Hello"); } }'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(mockCode); + + await expect( + workerDeployCommand('my-worker', { + code: './worker.js', + env: ['123INVALID=value'], + }) + ).rejects.toThrow('process.exit called'); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid env var name') + ); + }); }); describe('workerListCommand', () => { diff --git a/packages/atxp/src/commands/paas/worker.ts b/packages/atxp/src/commands/paas/worker.ts index 2fe5a3e..8e7028e 100644 --- a/packages/atxp/src/commands/paas/worker.ts +++ b/packages/atxp/src/commands/paas/worker.ts @@ -15,12 +15,12 @@ interface WorkerDeployOptions { } // Base reserved env var names that conflict with existing bindings -const BASE_RESERVED_ENV_NAMES = ['DB', 'BUCKET', 'USER_NAMESPACE']; +export const BASE_RESERVED_ENV_NAMES = ['DB', 'BUCKET', 'USER_NAMESPACE']; /** * Get reserved env var names based on analytics binding configuration */ -function getReservedEnvNames(analyticsBindingName?: string): string[] { +export function getReservedEnvNames(analyticsBindingName?: string): string[] { // If analytics is enabled, add the binding name to reserved list // Default binding name is 'ANALYTICS' const bindingName = analyticsBindingName || 'ANALYTICS'; @@ -28,13 +28,13 @@ function getReservedEnvNames(analyticsBindingName?: string): string[] { } // Patterns that suggest sensitive data (warn user about plain text storage) -const SENSITIVE_PATTERNS = [/SECRET/i, /PASSWORD/i, /KEY/i, /TOKEN/i, /CREDENTIAL/i]; +export const SENSITIVE_PATTERNS = [/SECRET/i, /PASSWORD/i, /KEY/i, /TOKEN/i, /CREDENTIAL/i]; /** * Validate an environment variable name * Must be a valid identifier and not reserved */ -function validateEnvVarName(name: string, reservedNames: string[]): { valid: boolean; error?: string } { +export function validateEnvVarName(name: string, reservedNames: string[]): { valid: boolean; error?: string } { // Check if it's a valid identifier (starts with letter or underscore, contains only alphanumeric and underscores) if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { return { valid: false, error: `Invalid env var name "${name}": must be a valid identifier (letters, numbers, underscores, cannot start with number)` }; @@ -51,7 +51,7 @@ function validateEnvVarName(name: string, reservedNames: string[]): { valid: boo /** * Parse a KEY=VALUE string into key and value */ -function parseEnvArg(arg: string): { key: string; value: string } | null { +export function parseEnvArg(arg: string): { key: string; value: string } | null { const eqIndex = arg.indexOf('='); if (eqIndex === -1) { return null; @@ -69,7 +69,7 @@ function parseEnvArg(arg: string): { key: string; value: string } | null { * - Empty lines * - Quoted values (single or double quotes) */ -function parseEnvFile(filePath: string): Record { +export function parseEnvFile(filePath: string): Record { const absolutePath = path.resolve(filePath); if (!fs.existsSync(absolutePath)) { throw new Error(`Env file not found: ${absolutePath}`);