Add comprehensive WebSocket support for v-hono framework, inspired by Hono.js WebSocket Helper. Features: - upgrade_websocket() function for WebSocket route handlers - Event-based callbacks: on_open, on_message, on_close, on_error - WSContext with send(), send_bytes(), send_json(), close() methods - Connection state tracking (connecting, open, closing, closed) - Configurable options: ping_interval, max_message_size, timeout, protocols - Subprotocol negotiation support - Ping/pong keep-alive handling - Message size validation with 1009 close code - Integration with picoev and uSockets backends Protocol Compliance: - RFC 6455 compliant handshake with Sec-WebSocket-Accept computation - Frame encoding/decoding with proper masking - Close handshake with status codes and reasons - Text and binary message frame support Testing: - 13 property-based test files with 82 tests total - Validates handshake, frame encoding, masking, JSON round-trip - Tests context preservation, route patterns, connection state - All existing tests pass (no regressions) Files added: - websocket.v: Core WebSocket implementation - examples/websocket_demo.v: Chat room example - examples/websocket_test.html: Browser test client - tests/test_websocket_*.v: Property-based tests Files modified: - app.v: Add ws() method for WebSocket routes - middleware.v: Export WebSocket types and functions - picoev_server.v: WebSocket upgrade detection - usockets_server.v: WebSocket upgrade detection - README.md: WebSocket documentation
579 lines
14 KiB
V
579 lines
14 KiB
V
// test_websocket_ping_pong.v - Property test for WebSocket ping/pong handling
|
|
// Feature: websocket-helper, Property 11: Ping/Pong Handling
|
|
// Validates: Requirements 7.4
|
|
//
|
|
// *For any* ping frame received from a client, the server SHALL respond with
|
|
// a pong frame containing the same payload data.
|
|
module main
|
|
|
|
import rand
|
|
import time
|
|
|
|
const test_iterations = 100
|
|
|
|
// WebSocket opcodes (RFC 6455)
|
|
const ws_opcode_ping = u8(0x9)
|
|
const ws_opcode_pong = u8(0xA)
|
|
|
|
// WebSocket Ready State
|
|
enum WSReadyState {
|
|
connecting = 0
|
|
open = 1
|
|
closing = 2
|
|
closed = 3
|
|
}
|
|
|
|
struct PropertyTestStats {
|
|
mut:
|
|
total_tests int
|
|
passed_tests int
|
|
failed_tests int
|
|
}
|
|
|
|
fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
|
|
stats.total_tests++
|
|
print('🔬 ${test_name}... ')
|
|
|
|
if test_func() {
|
|
stats.passed_tests++
|
|
println('✅')
|
|
} else {
|
|
stats.failed_tests++
|
|
println('❌')
|
|
}
|
|
}
|
|
|
|
fn (stats PropertyTestStats) print_summary() {
|
|
println('\n=== WebSocket Ping/Pong Property Test Summary ===')
|
|
println('Total tests: ${stats.total_tests}')
|
|
println('Passed: ${stats.passed_tests}')
|
|
println('Failed: ${stats.failed_tests}')
|
|
|
|
if stats.failed_tests == 0 {
|
|
println('🎉 All property tests passed!')
|
|
} else {
|
|
println('⚠️ ${stats.failed_tests} property test(s) failed')
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Frame Encoding/Decoding (copied from websocket.v for testing)
|
|
// ============================================================================
|
|
|
|
fn encode_ws_frame(opcode u8, payload []u8, masked bool) []u8 {
|
|
payload_len := payload.len
|
|
mut frame := []u8{cap: 14 + payload_len}
|
|
|
|
frame << u8(0x80 | (opcode & 0x0F))
|
|
|
|
mut mask_bit := u8(0)
|
|
if masked {
|
|
mask_bit = 0x80
|
|
}
|
|
|
|
if payload_len <= 125 {
|
|
frame << mask_bit | u8(payload_len)
|
|
} else if payload_len <= 65535 {
|
|
frame << mask_bit | u8(126)
|
|
frame << u8(payload_len >> 8)
|
|
frame << u8(payload_len & 0xFF)
|
|
} else {
|
|
frame << mask_bit | u8(127)
|
|
for i := 7; i >= 0; i-- {
|
|
frame << u8((payload_len >> (i * 8)) & 0xFF)
|
|
}
|
|
}
|
|
|
|
if masked {
|
|
mask_key := [u8(0x12), 0x34, 0x56, 0x78]
|
|
frame << mask_key[0]
|
|
frame << mask_key[1]
|
|
frame << mask_key[2]
|
|
frame << mask_key[3]
|
|
|
|
for i, b in payload {
|
|
frame << b ^ mask_key[i % 4]
|
|
}
|
|
} else {
|
|
frame << payload
|
|
}
|
|
|
|
return frame
|
|
}
|
|
|
|
fn decode_ws_frame(data []u8) !(bool, u8, []u8) {
|
|
if data.len < 2 {
|
|
return error('Frame too short')
|
|
}
|
|
|
|
fin := (data[0] & 0x80) != 0
|
|
opcode := data[0] & 0x0F
|
|
|
|
masked := (data[1] & 0x80) != 0
|
|
mut payload_len := u64(data[1] & 0x7F)
|
|
mut offset := 2
|
|
|
|
if payload_len == 126 {
|
|
if data.len < 4 {
|
|
return error('Frame too short for extended length')
|
|
}
|
|
payload_len = u64(data[2]) << 8 | u64(data[3])
|
|
offset = 4
|
|
} else if payload_len == 127 {
|
|
if data.len < 10 {
|
|
return error('Frame too short for 64-bit length')
|
|
}
|
|
payload_len = 0
|
|
for i := 0; i < 8; i++ {
|
|
payload_len = (payload_len << 8) | u64(data[2 + i])
|
|
}
|
|
offset = 10
|
|
}
|
|
|
|
mut mask_key := [4]u8{}
|
|
if masked {
|
|
if data.len < offset + 4 {
|
|
return error('Frame too short for mask key')
|
|
}
|
|
mask_key[0] = data[offset]
|
|
mask_key[1] = data[offset + 1]
|
|
mask_key[2] = data[offset + 2]
|
|
mask_key[3] = data[offset + 3]
|
|
offset += 4
|
|
}
|
|
|
|
if data.len < offset + int(payload_len) {
|
|
return error('Frame too short for payload')
|
|
}
|
|
|
|
mut payload := data[offset..offset + int(payload_len)].clone()
|
|
if masked {
|
|
for i := 0; i < payload.len; i++ {
|
|
payload[i] = payload[i] ^ mask_key[i % 4]
|
|
}
|
|
}
|
|
|
|
return fin, opcode, payload
|
|
}
|
|
|
|
// ============================================================================
|
|
// Mock WSContext for testing
|
|
// ============================================================================
|
|
|
|
struct MockWSContext {
|
|
mut:
|
|
ready_state WSReadyState
|
|
sent_frames [][]u8
|
|
ping_interval int
|
|
last_ping_sent i64
|
|
last_pong_received i64
|
|
pong_timeout int
|
|
awaiting_pong bool
|
|
}
|
|
|
|
fn create_mock_ws_context() MockWSContext {
|
|
return MockWSContext{
|
|
ready_state: .open
|
|
sent_frames: [][]u8{}
|
|
ping_interval: 30000
|
|
pong_timeout: 10000
|
|
}
|
|
}
|
|
|
|
fn (mut ws MockWSContext) send_ping(payload []u8) ![]u8 {
|
|
if ws.ready_state != .open {
|
|
return error('WebSocket connection is not open')
|
|
}
|
|
|
|
if payload.len > 125 {
|
|
return error('Ping payload too large (max 125 bytes)')
|
|
}
|
|
|
|
frame := encode_ws_frame(ws_opcode_ping, payload, false)
|
|
ws.sent_frames << frame
|
|
ws.last_ping_sent = time.now().unix()
|
|
ws.awaiting_pong = true
|
|
|
|
return frame
|
|
}
|
|
|
|
fn (mut ws MockWSContext) handle_ping(payload []u8) ![]u8 {
|
|
if ws.ready_state != .open {
|
|
return error('WebSocket connection is not open')
|
|
}
|
|
|
|
// Build pong frame with same payload
|
|
pong_frame := encode_ws_frame(ws_opcode_pong, payload, false)
|
|
ws.sent_frames << pong_frame
|
|
|
|
return pong_frame
|
|
}
|
|
|
|
fn (mut ws MockWSContext) handle_pong(payload []u8) {
|
|
ws.last_pong_received = time.now().unix()
|
|
ws.awaiting_pong = false
|
|
}
|
|
|
|
fn (ws MockWSContext) is_pong_timeout() bool {
|
|
if !ws.awaiting_pong {
|
|
return false
|
|
}
|
|
|
|
if ws.pong_timeout <= 0 {
|
|
return false
|
|
}
|
|
|
|
elapsed := time.now().unix() - ws.last_ping_sent
|
|
timeout_seconds := ws.pong_timeout / 1000
|
|
|
|
return elapsed > timeout_seconds
|
|
}
|
|
|
|
fn (ws MockWSContext) should_send_ping() bool {
|
|
if ws.ping_interval <= 0 {
|
|
return false
|
|
}
|
|
|
|
if ws.ready_state != .open {
|
|
return false
|
|
}
|
|
|
|
if ws.awaiting_pong {
|
|
return false
|
|
}
|
|
|
|
elapsed := time.now().unix() - ws.last_ping_sent
|
|
interval_seconds := ws.ping_interval / 1000
|
|
|
|
return elapsed >= interval_seconds
|
|
}
|
|
|
|
// ============================================================================
|
|
// Random Data Generators
|
|
// ============================================================================
|
|
|
|
fn generate_random_ping_payload() []u8 {
|
|
// Ping payload must be 125 bytes or less per RFC 6455
|
|
len := rand.int_in_range(0, 126) or { 50 }
|
|
mut result := []u8{cap: len}
|
|
for _ in 0 .. len {
|
|
result << u8(rand.int_in_range(0, 256) or { 0 })
|
|
}
|
|
return result
|
|
}
|
|
|
|
fn generate_small_payload() []u8 {
|
|
len := rand.int_in_range(0, 20) or { 10 }
|
|
mut result := []u8{cap: len}
|
|
for _ in 0 .. len {
|
|
result << u8(rand.int_in_range(0, 256) or { 0 })
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ============================================================================
|
|
// Property 11: Ping/Pong Handling
|
|
// Feature: websocket-helper, Property 11: Ping/Pong Handling
|
|
// Validates: Requirements 7.4
|
|
//
|
|
// *For any* ping frame received from a client, the server SHALL respond with
|
|
// a pong frame containing the same payload data.
|
|
// ============================================================================
|
|
|
|
fn test_property_11_pong_echoes_ping_payload() bool {
|
|
rand.seed([u32(time.now().unix()), u32(12345)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
ping_payload := generate_random_ping_payload()
|
|
|
|
mut ws := create_mock_ws_context()
|
|
|
|
// Handle incoming ping
|
|
pong_frame := ws.handle_ping(ping_payload) or {
|
|
println(' Iteration ${i}: Failed to handle ping: ${err}')
|
|
return false
|
|
}
|
|
|
|
// Decode the pong frame
|
|
fin, opcode, pong_payload := decode_ws_frame(pong_frame) or {
|
|
println(' Iteration ${i}: Failed to decode pong frame: ${err}')
|
|
return false
|
|
}
|
|
|
|
// Verify it's a pong frame
|
|
if opcode != ws_opcode_pong {
|
|
println(' Iteration ${i}: Expected pong opcode (0xA), got 0x${opcode:02x}')
|
|
return false
|
|
}
|
|
|
|
// Verify FIN bit is set
|
|
if !fin {
|
|
println(' Iteration ${i}: FIN bit should be set for pong frame')
|
|
return false
|
|
}
|
|
|
|
// Verify payload matches ping payload
|
|
if pong_payload != ping_payload {
|
|
println(' Iteration ${i}: Pong payload should match ping payload')
|
|
println(' Ping payload length: ${ping_payload.len}')
|
|
println(' Pong payload length: ${pong_payload.len}')
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
fn test_property_11_ping_sends_frame() bool {
|
|
rand.seed([u32(time.now().unix()), u32(54321)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
payload := generate_small_payload()
|
|
|
|
mut ws := create_mock_ws_context()
|
|
|
|
// Send ping
|
|
ping_frame := ws.send_ping(payload) or {
|
|
println(' Iteration ${i}: Failed to send ping: ${err}')
|
|
return false
|
|
}
|
|
|
|
// Decode the ping frame
|
|
fin, opcode, decoded_payload := decode_ws_frame(ping_frame) or {
|
|
println(' Iteration ${i}: Failed to decode ping frame: ${err}')
|
|
return false
|
|
}
|
|
|
|
// Verify it's a ping frame
|
|
if opcode != ws_opcode_ping {
|
|
println(' Iteration ${i}: Expected ping opcode (0x9), got 0x${opcode:02x}')
|
|
return false
|
|
}
|
|
|
|
// Verify FIN bit is set
|
|
if !fin {
|
|
println(' Iteration ${i}: FIN bit should be set for ping frame')
|
|
return false
|
|
}
|
|
|
|
// Verify payload matches
|
|
if decoded_payload != payload {
|
|
println(' Iteration ${i}: Ping payload mismatch')
|
|
return false
|
|
}
|
|
|
|
// Verify awaiting_pong is set
|
|
if !ws.awaiting_pong {
|
|
println(' Iteration ${i}: awaiting_pong should be true after sending ping')
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
fn test_property_11_pong_clears_awaiting_flag() bool {
|
|
rand.seed([u32(time.now().unix()), u32(99999)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
payload := generate_small_payload()
|
|
|
|
mut ws := create_mock_ws_context()
|
|
|
|
// Send ping
|
|
ws.send_ping(payload) or {
|
|
println(' Iteration ${i}: Failed to send ping: ${err}')
|
|
return false
|
|
}
|
|
|
|
// Verify awaiting_pong is set
|
|
if !ws.awaiting_pong {
|
|
println(' Iteration ${i}: awaiting_pong should be true after ping')
|
|
return false
|
|
}
|
|
|
|
// Handle pong
|
|
ws.handle_pong(payload)
|
|
|
|
// Verify awaiting_pong is cleared
|
|
if ws.awaiting_pong {
|
|
println(' Iteration ${i}: awaiting_pong should be false after pong')
|
|
return false
|
|
}
|
|
|
|
// Verify last_pong_received is updated
|
|
if ws.last_pong_received <= 0 {
|
|
println(' Iteration ${i}: last_pong_received should be updated')
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
fn test_property_11_ping_payload_size_limit() bool {
|
|
// Test that ping payload > 125 bytes is rejected
|
|
mut ws := create_mock_ws_context()
|
|
|
|
// Create payload larger than 125 bytes
|
|
large_payload := []u8{len: 126, init: u8(0x42)}
|
|
|
|
// Should fail
|
|
ws.send_ping(large_payload) or {
|
|
// Expected error
|
|
return true
|
|
}
|
|
|
|
println(' Large ping payload should be rejected')
|
|
return false
|
|
}
|
|
|
|
fn test_property_11_ping_requires_open_connection() bool {
|
|
// Test that ping fails on non-open connections
|
|
states := [WSReadyState.connecting, .closing, .closed]
|
|
|
|
for state in states {
|
|
mut ws := create_mock_ws_context()
|
|
ws.ready_state = state
|
|
|
|
ws.send_ping([]u8{}) or {
|
|
// Expected error
|
|
continue
|
|
}
|
|
|
|
println(' Ping should fail in ${state} state')
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
fn test_property_11_should_send_ping_logic() bool {
|
|
// Test should_send_ping logic
|
|
|
|
// Case 1: Ping disabled (interval = 0)
|
|
mut ws1 := create_mock_ws_context()
|
|
ws1.ping_interval = 0
|
|
if ws1.should_send_ping() {
|
|
println(' should_send_ping should be false when interval is 0')
|
|
return false
|
|
}
|
|
|
|
// Case 2: Connection not open
|
|
mut ws2 := create_mock_ws_context()
|
|
ws2.ready_state = .closing
|
|
if ws2.should_send_ping() {
|
|
println(' should_send_ping should be false when not open')
|
|
return false
|
|
}
|
|
|
|
// Case 3: Already awaiting pong
|
|
mut ws3 := create_mock_ws_context()
|
|
ws3.awaiting_pong = true
|
|
if ws3.should_send_ping() {
|
|
println(' should_send_ping should be false when awaiting pong')
|
|
return false
|
|
}
|
|
|
|
// Case 4: Not enough time elapsed
|
|
mut ws4 := create_mock_ws_context()
|
|
ws4.ping_interval = 30000 // 30 seconds
|
|
ws4.last_ping_sent = time.now().unix() // Just sent
|
|
if ws4.should_send_ping() {
|
|
println(' should_send_ping should be false when interval not elapsed')
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
fn test_ping_pong_round_trip() bool {
|
|
rand.seed([u32(time.now().unix()), u32(77777)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
payload := generate_random_ping_payload()
|
|
|
|
// Build ping frame
|
|
ping_frame := encode_ws_frame(ws_opcode_ping, payload, false)
|
|
|
|
// Decode ping frame
|
|
_, ping_opcode, ping_payload := decode_ws_frame(ping_frame) or {
|
|
println(' Iteration ${i}: Failed to decode ping frame: ${err}')
|
|
return false
|
|
}
|
|
|
|
if ping_opcode != ws_opcode_ping {
|
|
println(' Iteration ${i}: Expected ping opcode')
|
|
return false
|
|
}
|
|
|
|
// Build pong frame with same payload
|
|
pong_frame := encode_ws_frame(ws_opcode_pong, ping_payload, false)
|
|
|
|
// Decode pong frame
|
|
_, pong_opcode, pong_payload := decode_ws_frame(pong_frame) or {
|
|
println(' Iteration ${i}: Failed to decode pong frame: ${err}')
|
|
return false
|
|
}
|
|
|
|
if pong_opcode != ws_opcode_pong {
|
|
println(' Iteration ${i}: Expected pong opcode')
|
|
return false
|
|
}
|
|
|
|
// Verify payload round-trip
|
|
if pong_payload != payload {
|
|
println(' Iteration ${i}: Payload mismatch in round-trip')
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
fn test_empty_ping_payload() bool {
|
|
mut ws := create_mock_ws_context()
|
|
|
|
// Empty payload should work
|
|
pong_frame := ws.handle_ping([]u8{}) or {
|
|
println(' Empty ping payload should be handled')
|
|
return false
|
|
}
|
|
|
|
// Decode and verify
|
|
_, opcode, payload := decode_ws_frame(pong_frame) or {
|
|
println(' Failed to decode pong frame')
|
|
return false
|
|
}
|
|
|
|
if opcode != ws_opcode_pong {
|
|
println(' Expected pong opcode')
|
|
return false
|
|
}
|
|
|
|
if payload.len != 0 {
|
|
println(' Pong payload should be empty')
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
fn main() {
|
|
println('🚀 Starting WebSocket Ping/Pong Property Tests...')
|
|
println('Each property test runs ${test_iterations} iterations\n')
|
|
|
|
mut stats := PropertyTestStats{}
|
|
|
|
// Run property tests
|
|
// Feature: websocket-helper, Property 11: Ping/Pong Handling
|
|
// Validates: Requirements 7.4
|
|
stats.run_property_test('Property 11: Pong Echoes Ping Payload', test_property_11_pong_echoes_ping_payload)
|
|
stats.run_property_test('Property 11: Ping Sends Frame', test_property_11_ping_sends_frame)
|
|
stats.run_property_test('Property 11: Pong Clears Awaiting Flag', test_property_11_pong_clears_awaiting_flag)
|
|
stats.run_property_test('Property 11: Ping Payload Size Limit', test_property_11_ping_payload_size_limit)
|
|
stats.run_property_test('Property 11: Ping Requires Open Connection', test_property_11_ping_requires_open_connection)
|
|
stats.run_property_test('Property 11: Should Send Ping Logic', test_property_11_should_send_ping_logic)
|
|
stats.run_property_test('Ping/Pong Round Trip', test_ping_pong_round_trip)
|
|
stats.run_property_test('Empty Ping Payload', test_empty_ping_payload)
|
|
|
|
// Print test summary
|
|
stats.print_summary()
|
|
}
|