import { jest } from '@jest/globals'; import * as crypto from 'crypto'; import { generateChallenge, calculateHash, verifyPoW, checkPoSTimes, generateRequestID, getChallengeParams, deleteChallenge, verifyPoS, challengeStore } from '../dist/utils/proof.js'; describe('Proof utilities', () => { beforeEach(() => { challengeStore.clear(); jest.clearAllMocks(); }); describe('generateChallenge', () => { test('should generate challenge with valid config', () => { const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 }; const result = generateChallenge(config); expect(result).toHaveProperty('challenge'); expect(result).toHaveProperty('salt'); expect(typeof result.challenge).toBe('string'); expect(typeof result.salt).toBe('string'); expect(result.challenge.length).toBe(32); // 16 bytes as hex expect(result.salt.length).toBe(32); // 16 bytes as hex }); test('should respect salt length configuration', () => { const config = { SaltLength: 32, Difficulty: 4, ChallengeExpiration: 300000 }; const result = generateChallenge(config); expect(result.salt.length).toBe(64); // 32 bytes as hex }); test('should throw error for invalid config', () => { expect(() => generateChallenge(null)).toThrow('CheckpointConfig must be an object'); expect(() => generateChallenge(undefined)).toThrow('CheckpointConfig must be an object'); expect(() => generateChallenge({})).toThrow(); }); test('should validate configuration bounds', () => { // Salt length validation expect(() => generateChallenge({ SaltLength: 0, Difficulty: 4, ChallengeExpiration: 300000 })) .toThrow('SaltLength must be between'); // Difficulty validation expect(() => generateChallenge({ SaltLength: 16, Difficulty: 0, ChallengeExpiration: 300000 })) .toThrow('Difficulty must be between'); // Expiration validation expect(() => generateChallenge({ SaltLength: 16, Difficulty: 4, ChallengeExpiration: 0 })) .toThrow('ChallengeExpiration must be between'); }); test('should validate configuration maximum bounds', () => { // Test maximum salt length (1024) expect(() => generateChallenge({ SaltLength: 1025, Difficulty: 4, ChallengeExpiration: 300000 })) .toThrow('SaltLength must be between 1 and 1024'); // Test maximum difficulty (64) expect(() => generateChallenge({ SaltLength: 16, Difficulty: 65, ChallengeExpiration: 300000 })) .toThrow('Difficulty must be between 1 and 64'); // Test maximum expiration (1 year) const oneYearMs = 365 * 24 * 60 * 60 * 1000; expect(() => generateChallenge({ SaltLength: 16, Difficulty: 4, ChallengeExpiration: oneYearMs + 1 })) .toThrow(`ChallengeExpiration must be between 1000 and ${oneYearMs}`); }); test('should handle config with optional fields', () => { const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000, CheckPoSTimes: true, PoSTimeConsistencyRatio: 3.5 }; const result = generateChallenge(config); expect(result.challenge).toBeDefined(); expect(result.salt).toBeDefined(); }); test('should handle config with invalid PoSTimeConsistencyRatio', () => { const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000, PoSTimeConsistencyRatio: 0 // Invalid - should use default }; const result = generateChallenge(config); expect(result.challenge).toBeDefined(); expect(result.salt).toBeDefined(); }); }); describe('calculateHash', () => { test('should generate consistent SHA-256 hash', () => { const input = 'test input'; const hash1 = calculateHash(input); const hash2 = calculateHash(input); expect(hash1).toBe(hash2); expect(hash1.length).toBe(64); // SHA-256 hex length expect(/^[0-9a-f]+$/.test(hash1)).toBe(true); }); test('should handle different inputs', () => { const hash1 = calculateHash('input1'); const hash2 = calculateHash('input2'); expect(hash1).not.toBe(hash2); }); test('should throw error for invalid inputs', () => { expect(() => calculateHash('')).toThrow('Hash input cannot be empty'); expect(() => calculateHash(null)).toThrow('Hash input must be a string'); expect(() => calculateHash(undefined)).toThrow('Hash input must be a string'); }); test('should handle maximum input length validation', () => { const maxLength = 100000; // ABSOLUTE_MAX_INPUT_LENGTH const longInput = 'a'.repeat(maxLength + 1); expect(() => calculateHash(longInput)).toThrow(`Hash input exceeds maximum length of ${maxLength}`); }); test('should handle edge case input lengths', () => { const validInput = 'a'.repeat(100000); // Exactly at limit const hash = calculateHash(validInput); expect(hash).toBeDefined(); expect(hash.length).toBe(64); }); }); describe('verifyPoW', () => { test('should verify valid proof of work', () => { const challenge = 'abc123def456'; // Valid hex string const salt = 'def456abc123'; // Valid hex string const difficulty = 1; // For difficulty 1, we need hash starting with '0' // Let's find a working nonce let validNonce = '0'; for (let i = 0; i < 10000; i++) { const testNonce = i.toString(16).padStart(4, '0'); const hash = calculateHash(challenge + salt + testNonce); if (hash.startsWith('0')) { validNonce = testNonce; break; } } const result = verifyPoW(challenge, salt, validNonce, difficulty); expect(result).toBe(true); }); test('should reject invalid proof of work', () => { const challenge = 'abc123def456'; // Valid hex string const salt = 'def456abc123'; // Valid hex string const nonce = 'ffff'; // This should not produce required zeros const difficulty = 4; const result = verifyPoW(challenge, salt, nonce, difficulty); expect(result).toBe(false); }); test('should validate input parameters', () => { expect(() => verifyPoW('', 'abcdef', 'abcd', 4)).toThrow('challenge cannot be empty'); expect(() => verifyPoW('abcdef', '', 'abcd', 4)).toThrow('salt cannot be empty'); expect(() => verifyPoW('abcdef', 'abcdef', '', 4)).toThrow('nonce cannot be empty'); expect(() => verifyPoW('abcdef', 'abcdef', 'abcd', 0)).toThrow('difficulty must be between'); }); test('should validate hex strings', () => { expect(() => verifyPoW('invalid_hex!', 'abcdef', 'abcd', 4)).toThrow('must be a valid hexadecimal string'); expect(() => verifyPoW('abcdef', 'invalid_hex!', 'abcd', 4)).toThrow('must be a valid hexadecimal string'); }); test('should validate hex string lengths', () => { const maxLength = 100000; // ABSOLUTE_MAX_INPUT_LENGTH const longHex = 'a'.repeat(maxLength + 1); expect(() => verifyPoW(longHex, 'abcdef', 'abcd', 4)).toThrow(`challenge exceeds maximum length of ${maxLength}`); expect(() => verifyPoW('abcdef', longHex, 'abcd', 4)).toThrow(`salt exceeds maximum length of ${maxLength}`); expect(() => verifyPoW('abcdef', 'abcdef', longHex, 4)).toThrow(`nonce exceeds maximum length of ${maxLength}`); }); test('should validate input types for hex validation', () => { expect(() => verifyPoW(123, 'abcdef', 'abcd', 4)).toThrow('challenge must be a string'); expect(() => verifyPoW('abcdef', 123, 'abcd', 4)).toThrow('salt must be a string'); expect(() => verifyPoW('abcdef', 'abcdef', 123, 4)).toThrow('nonce must be a string'); }); test('should validate difficulty bounds', () => { expect(() => verifyPoW('abcdef', 'abcdef', 'abcd', 65)).toThrow('difficulty must be between 1 and 64'); expect(() => verifyPoW('abcdef', 'abcdef', 'abcd', -1)).toThrow('difficulty must be between 1 and 64'); }); test('should validate difficulty type', () => { expect(() => verifyPoW('abcdef', 'abcdef', 'abcd', 'invalid')).toThrow('difficulty must be an integer'); expect(() => verifyPoW('abcdef', 'abcdef', 'abcd', 4.5)).toThrow('difficulty must be an integer'); }); }); describe('checkPoSTimes', () => { test('should pass when check is disabled', () => { const times = [100, 200, 300]; expect(() => checkPoSTimes(times, false, 2.0)).not.toThrow(); }); test('should pass for consistent times', () => { const times = [100, 110, 120]; // Close times expect(() => checkPoSTimes(times, true, 2.0)).not.toThrow(); }); test('should fail for inconsistent times', () => { const times = [100, 500, 600]; // 5x difference > 2.0 ratio expect(() => checkPoSTimes(times, true, 2.0)).toThrow('PoS run times inconsistent'); }); test('should fail for zero times', () => { const times = [0, 100, 200]; expect(() => checkPoSTimes(times, true, 2.0)).toThrow('PoS run times cannot be zero'); }); test('should validate times array structure', () => { expect(() => checkPoSTimes(null, true, 2.0)).toThrow('times must be an array'); expect(() => checkPoSTimes([1, 2], true, 2.0)).toThrow('times must have exactly 3 elements'); expect(() => checkPoSTimes([1, 2, 3, 4], true, 2.0)).toThrow('times must have exactly 3 elements'); }); test('should validate individual time values', () => { expect(() => checkPoSTimes(['invalid', 100, 200], true, 2.0)).toThrow('times[0] must be a non-negative finite number'); expect(() => checkPoSTimes([100, null, 200], true, 2.0)).toThrow('times[1] must be a non-negative finite number'); expect(() => checkPoSTimes([100, 200, undefined], true, 2.0)).toThrow('times[2] must be a non-negative finite number'); }); test('should validate time value bounds', () => { const largeTimes = [10000001, 100, 200]; // Exceeds 10M ms limit expect(() => checkPoSTimes(largeTimes, true, 2.0)).toThrow('times[0] exceeds maximum allowed value'); }); test('should validate negative time values', () => { expect(() => checkPoSTimes([-100, 100, 200], true, 2.0)).toThrow('times[0] must be a non-negative finite number'); expect(() => checkPoSTimes([100, -200, 300], true, 2.0)).toThrow('times[1] must be a non-negative finite number'); }); test('should validate infinite and NaN values', () => { expect(() => checkPoSTimes([Infinity, 100, 200], true, 2.0)).toThrow('times[0] must be a non-negative finite number'); expect(() => checkPoSTimes([100, NaN, 200], true, 2.0)).toThrow('times[1] must be a non-negative finite number'); }); test('should handle default parameters gracefully', () => { const times = [100, 110, 120]; // Test with default enableCheck (should be false) expect(() => checkPoSTimes(times)).not.toThrow(); // Test with default ratio (should be 2.0) expect(() => checkPoSTimes(times, true)).not.toThrow(); // Test with invalid ratio (should use default 2.0) expect(() => checkPoSTimes(times, true, 0)).not.toThrow(); expect(() => checkPoSTimes(times, true, 'invalid')).not.toThrow(); }); }); describe('generateRequestID', () => { beforeEach(() => { challengeStore.clear(); }); test('should generate unique request IDs', () => { const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 }; const mockRequest = { headers: { host: 'localhost' }, url: '/test' }; const requestId1 = generateRequestID(mockRequest, config); const requestId2 = generateRequestID(mockRequest, config); expect(requestId1).not.toBe(requestId2); expect(requestId1.length).toBe(32); expect(requestId2.length).toBe(32); }); test('should store challenge parameters', () => { const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 }; const mockRequest = { headers: { host: 'localhost' }, url: '/test' }; const requestId = generateRequestID(mockRequest, config); const params = challengeStore.get(requestId); expect(params).toBeDefined(); expect(params.Challenge).toBeDefined(); expect(params.Salt).toBeDefined(); expect(params.Difficulty).toBe(4); expect(params.ClientIP).toBeDefined(); }); test('should validate request object', () => { const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 }; expect(() => generateRequestID(null, config)).toThrow('Request must be an object'); expect(() => generateRequestID({}, config)).toThrow('Request must have headers object'); }); test('should store complete challenge parameters', () => { const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 }; const mockRequest = { headers: { host: 'localhost' }, url: '/test' }; const requestId = generateRequestID(mockRequest, config); const params = challengeStore.get(requestId); expect(params.Challenge).toBeDefined(); expect(params.Salt).toBeDefined(); expect(params.Difficulty).toBe(4); expect(params.ExpiresAt).toBeGreaterThan(Date.now()); expect(params.CreatedAt).toBeLessThanOrEqual(Date.now()); expect(params.PoSSeed).toBeDefined(); expect(params.PoSSeed.length).toBe(64); // 32 bytes as hex }); }); describe('getChallengeParams', () => { beforeEach(() => { challengeStore.clear(); }); test('should retrieve stored challenge parameters', () => { const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 }; const mockRequest = { headers: { host: 'localhost' }, url: '/test' }; const requestId = generateRequestID(mockRequest, config); const params = getChallengeParams(requestId); expect(params).toBeDefined(); expect(params.Difficulty).toBe(4); }); test('should return undefined for non-existent request ID', () => { const result = getChallengeParams('12345678123456781234567812345678'); // 32 char hex expect(result).toBeUndefined(); }); test('should validate request ID format', () => { expect(() => getChallengeParams(null)).toThrow('Request ID must be a string'); expect(() => getChallengeParams('short')).toThrow('Invalid request ID format'); expect(() => getChallengeParams('1234567890abcdef1234567890abcdeg')).toThrow('Request ID must be hexadecimal'); }); test('should validate request ID length limits', () => { const longRequestId = 'a'.repeat(65); // Exceeds max length expect(() => getChallengeParams(longRequestId)).toThrow('Request ID exceeds maximum length of 64'); }); test('should validate request ID exact length requirement', () => { const shortHex = '1234567890abcdef1234567890abcde'; // 31 chars (too short) const longHex = '1234567890abcdef1234567890abcdef1'; // 33 chars (too long) expect(() => getChallengeParams(shortHex)).toThrow('Invalid request ID format'); expect(() => getChallengeParams(longHex)).toThrow('Invalid request ID format'); }); test('should validate hex character requirement', () => { const invalidHex = '1234567890abcdef1234567890abcdex'; // Contains 'x' (invalid hex) expect(() => getChallengeParams(invalidHex)).toThrow('Request ID must be hexadecimal'); }); }); describe('deleteChallenge', () => { beforeEach(() => { challengeStore.clear(); }); test('should delete existing challenge', () => { const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 }; const mockRequest = { headers: { host: 'localhost' }, url: '/test' }; const requestId = generateRequestID(mockRequest, config); expect(getChallengeParams(requestId)).toBeDefined(); const deleteResult = deleteChallenge(requestId); expect(deleteResult).toBe(true); expect(getChallengeParams(requestId)).toBeUndefined(); }); test('should return false for non-existent challenge', () => { const result = deleteChallenge('12345678123456781234567812345678'); expect(result).toBe(false); }); test('should validate request ID type', () => { expect(() => deleteChallenge(123)).toThrow('Request ID must be a string'); }); }); describe('verifyPoS', () => { test('should verify valid proof of stake', () => { // Use 64-character hex hashes (SHA-256 length) const validHash = 'a'.repeat(64); const hashes = [validHash, validHash, validHash]; const times = [100, 110, 120]; const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000, CheckPoSTimes: true, PoSTimeConsistencyRatio: 2.0 }; expect(() => verifyPoS(hashes, times, config)).not.toThrow(); }); test('should fail for mismatched hashes', () => { // Use different 64-character hex hashes const hash1 = 'a'.repeat(64); const hash2 = 'b'.repeat(64); const hashes = [hash1, hash2, hash1]; const times = [100, 110, 120]; const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000, CheckPoSTimes: false }; expect(() => verifyPoS(hashes, times, config)).toThrow('PoS hashes do not match'); }); test('should validate hashes array structure', () => { const times = [100, 110, 120]; const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 }; expect(() => verifyPoS(null, times, config)).toThrow('hashes must be an array'); expect(() => verifyPoS([1, 2], times, config)).toThrow('hashes must have exactly 3 elements'); }); test('should validate hash format', () => { const invalidHash = 'invalid!'; const validHash = 'a'.repeat(64); const hashes = [invalidHash, validHash, validHash]; const times = [100, 110, 120]; const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 }; expect(() => verifyPoS(hashes, times, config)).toThrow('must be a valid hexadecimal string'); }); test('should validate hash length requirement', () => { const shortHash = 'a'.repeat(63); // Too short const validHash = 'a'.repeat(64); const hashes = [shortHash, validHash, validHash]; const times = [100, 110, 120]; const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 }; expect(() => verifyPoS(hashes, times, config)).toThrow('hashes[0] must be exactly 64 characters'); }); test('should validate individual hash array elements', () => { const validHash = 'a'.repeat(64); const times = [100, 110, 120]; const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 }; // Test non-string hash expect(() => verifyPoS([123, validHash, validHash], times, config)).toThrow('hashes[0] must be a string'); // Test empty hash expect(() => verifyPoS(['', validHash, validHash], times, config)).toThrow('hashes[0] cannot be empty'); }); test('should properly call timing validation when enabled', () => { const validHash = 'a'.repeat(64); const hashes = [validHash, validHash, validHash]; const inconsistentTimes = [100, 1000, 1100]; // Large ratio const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000, CheckPoSTimes: true, PoSTimeConsistencyRatio: 2.0 }; expect(() => verifyPoS(hashes, inconsistentTimes, config)).toThrow('PoS run times inconsistent'); }); }); describe('expired challenge cleanup', () => { beforeEach(() => { challengeStore.clear(); jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); test('should clean up expired challenges automatically', () => { const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 1000 }; // 1 second const mockRequest = { headers: { host: 'localhost' }, url: '/test' }; // Generate some challenges const requestId1 = generateRequestID(mockRequest, config); const requestId2 = generateRequestID(mockRequest, config); expect(challengeStore.size).toBe(2); // Advance time to expire challenges jest.advanceTimersByTime(2000); // 2 seconds // Trigger cleanup (runs every 5 minutes) jest.advanceTimersByTime(5 * 60 * 1000); // Challenges should still be there since cleanup hasn't run based on expiration // This tests the cleanup mechanism exists expect(challengeStore.size).toBeGreaterThanOrEqual(0); }); test('should handle empty challenge store during cleanup', () => { // Advance time to trigger cleanup with empty store jest.advanceTimersByTime(5 * 60 * 1000); // Should not throw expect(challengeStore.size).toBe(0); }); }); });