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
535 lines
15 KiB
V
535 lines
15 KiB
V
// test_websocket_message_size.v - Property test for WebSocket message size limit
|
|
// Feature: websocket-helper, Property 8: Message Size Limit
|
|
// Validates: Requirements 4.2
|
|
//
|
|
// *For any* incoming WebSocket message exceeding the configured max_message_size,
|
|
// the server SHALL close the connection with status code 1009 (Message Too Big)
|
|
// and invoke the on_error callback.
|
|
module main
|
|
|
|
import rand
|
|
import time
|
|
|
|
const test_iterations = 100
|
|
|
|
// WebSocket opcodes (RFC 6455)
|
|
const ws_opcode_text = u8(0x1)
|
|
const ws_opcode_binary = u8(0x2)
|
|
const ws_opcode_close = u8(0x8)
|
|
const ws_opcode_ping = u8(0x9)
|
|
const ws_opcode_pong = u8(0xA)
|
|
|
|
// WebSocket close codes
|
|
const ws_close_message_too_big = 1009
|
|
|
|
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 Message Size Limit 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')
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// WebSocket Frame Structure (copied from websocket.v for testing)
|
|
// ============================================================================
|
|
|
|
struct WSFrame {
|
|
pub:
|
|
fin bool
|
|
opcode u8
|
|
masked bool
|
|
mask_key [4]u8
|
|
payload_len u64
|
|
payload []u8
|
|
}
|
|
|
|
// WSReadyState - WebSocket connection state enumeration
|
|
enum WSReadyState {
|
|
connecting = 0
|
|
open = 1
|
|
closing = 2
|
|
closed = 3
|
|
}
|
|
|
|
// WSStateChangeEvent - Event structure for state change notifications
|
|
struct WSStateChangeEvent {
|
|
pub:
|
|
previous_state WSReadyState
|
|
new_state WSReadyState
|
|
timestamp i64
|
|
}
|
|
|
|
// WSContext - Simplified WebSocket context for testing
|
|
struct WSContext {
|
|
pub mut:
|
|
ready_state WSReadyState
|
|
state_history []WSStateChangeEvent
|
|
close_code int
|
|
close_reason string
|
|
error_messages []string
|
|
}
|
|
|
|
fn (mut ws WSContext) force_close(code int, reason string) {
|
|
ws.close_code = code
|
|
ws.close_reason = reason
|
|
ws.ready_state = .closed
|
|
ws.state_history << WSStateChangeEvent{
|
|
previous_state: .open
|
|
new_state: .closed
|
|
timestamp: time.now().unix()
|
|
}
|
|
}
|
|
|
|
// WSMessageEvent - Message event structure
|
|
struct WSMessageEvent {
|
|
pub:
|
|
data string
|
|
data_bytes []u8
|
|
is_binary bool
|
|
}
|
|
|
|
// WSCloseEvent - Close event structure
|
|
struct WSCloseEvent {
|
|
pub:
|
|
code int
|
|
reason string
|
|
was_clean bool
|
|
}
|
|
|
|
// Event handler types
|
|
type WSOpenHandler = fn (mut ws WSContext)
|
|
type WSMessageHandler = fn (event WSMessageEvent, mut ws WSContext)
|
|
type WSCloseHandler = fn (event WSCloseEvent, mut ws WSContext)
|
|
type WSErrorHandler = fn (error string, mut ws WSContext)
|
|
|
|
// WSEvents - WebSocket event handlers configuration
|
|
struct WSEvents {
|
|
pub:
|
|
on_open ?WSOpenHandler
|
|
on_message ?WSMessageHandler
|
|
on_close ?WSCloseHandler
|
|
on_error ?WSErrorHandler
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Message Size Validation Function (copied from websocket.v for testing)
|
|
// ============================================================================
|
|
|
|
fn validate_message_size(frame WSFrame, max_message_size int, events WSEvents, mut ws WSContext) bool {
|
|
// Only validate data frames (text and binary)
|
|
if frame.opcode != ws_opcode_text && frame.opcode != ws_opcode_binary {
|
|
return true
|
|
}
|
|
|
|
// Check if message size exceeds the limit
|
|
if max_message_size > 0 && int(frame.payload_len) > max_message_size {
|
|
// Invoke on_error callback
|
|
error_msg := 'Message size ${frame.payload_len} exceeds maximum allowed size ${max_message_size}'
|
|
if on_error := events.on_error {
|
|
on_error(error_msg, mut ws)
|
|
}
|
|
|
|
// Close connection with 1009 (Message Too Big)
|
|
ws.force_close(ws_close_message_too_big, 'Message too big')
|
|
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// ============================================================================
|
|
// Random Data Generators
|
|
// ============================================================================
|
|
|
|
fn generate_random_payload(size int) []u8 {
|
|
mut result := []u8{cap: size}
|
|
for _ in 0 .. size {
|
|
result << u8(rand.int_in_range(0, 256) or { 0 })
|
|
}
|
|
return result
|
|
}
|
|
|
|
fn generate_random_text_payload(size int) []u8 {
|
|
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
|
mut result := []u8{cap: size}
|
|
for _ in 0 .. size {
|
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
|
result << chars[idx]
|
|
}
|
|
return result
|
|
}
|
|
|
|
fn create_test_frame(opcode u8, payload []u8) WSFrame {
|
|
return WSFrame{
|
|
fin: true
|
|
opcode: opcode
|
|
masked: false
|
|
mask_key: [4]u8{}
|
|
payload_len: u64(payload.len)
|
|
payload: payload
|
|
}
|
|
}
|
|
|
|
fn create_test_ws_context() WSContext {
|
|
return WSContext{
|
|
ready_state: .open
|
|
state_history: []
|
|
close_code: 0
|
|
close_reason: ''
|
|
error_messages: []
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Property 8: Message Size Limit
|
|
// Feature: websocket-helper, Property 8: Message Size Limit
|
|
// Validates: Requirements 4.2
|
|
//
|
|
// *For any* incoming WebSocket message exceeding the configured max_message_size,
|
|
// the server SHALL close the connection with status code 1009 (Message Too Big)
|
|
// and invoke the on_error callback.
|
|
// ============================================================================
|
|
|
|
fn test_property_8_oversized_messages_rejected() bool {
|
|
rand.seed([u32(time.now().unix()), u32(12345)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
// Generate random max_message_size between 100 and 10000
|
|
max_size := rand.int_in_range(100, 10000) or { 1000 }
|
|
|
|
// Generate a payload that exceeds the max size
|
|
excess := rand.int_in_range(1, 1000) or { 100 }
|
|
oversized_payload := generate_random_payload(max_size + excess)
|
|
|
|
// Create a text frame with oversized payload
|
|
frame := create_test_frame(ws_opcode_text, oversized_payload)
|
|
|
|
// Create test context
|
|
mut ws := create_test_ws_context()
|
|
|
|
// Create events with error handler that stores error in ws.error_messages
|
|
events := WSEvents{
|
|
on_error: fn (err string, mut ws WSContext) {
|
|
ws.error_messages << err
|
|
}
|
|
}
|
|
|
|
// Validate message size
|
|
result := validate_message_size(frame, max_size, events, mut ws)
|
|
|
|
// Verify validation returns false for oversized message
|
|
if result {
|
|
println(' Iteration ${i}: Expected validation to return false for oversized message')
|
|
println(' Max size: ${max_size}, Payload size: ${oversized_payload.len}')
|
|
return false
|
|
}
|
|
|
|
// Verify connection was closed with 1009
|
|
if ws.close_code != ws_close_message_too_big {
|
|
println(' Iteration ${i}: Expected close code 1009, got ${ws.close_code}')
|
|
return false
|
|
}
|
|
|
|
// Verify connection state is closed
|
|
if ws.ready_state != .closed {
|
|
println(' Iteration ${i}: Expected connection state to be closed')
|
|
return false
|
|
}
|
|
|
|
// Verify on_error was invoked by checking error_messages
|
|
if ws.error_messages.len == 0 {
|
|
println(' Iteration ${i}: Expected on_error callback to be invoked')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
fn test_property_8_valid_messages_accepted() bool {
|
|
rand.seed([u32(time.now().unix()), u32(54321)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
// Generate random max_message_size between 100 and 10000
|
|
max_size := rand.int_in_range(100, 10000) or { 1000 }
|
|
|
|
// Generate a payload that is within the limit
|
|
payload_size := rand.int_in_range(0, max_size) or { 50 }
|
|
valid_payload := generate_random_payload(payload_size)
|
|
|
|
// Create a text frame with valid payload
|
|
frame := create_test_frame(ws_opcode_text, valid_payload)
|
|
|
|
// Create test context
|
|
mut ws := create_test_ws_context()
|
|
|
|
// Create events with error handler
|
|
events := WSEvents{
|
|
on_error: fn (err string, mut ws WSContext) {
|
|
ws.error_messages << err
|
|
}
|
|
}
|
|
|
|
// Validate message size
|
|
result := validate_message_size(frame, max_size, events, mut ws)
|
|
|
|
// Verify validation returns true for valid message
|
|
if !result {
|
|
println(' Iteration ${i}: Expected validation to return true for valid message')
|
|
println(' Max size: ${max_size}, Payload size: ${valid_payload.len}')
|
|
return false
|
|
}
|
|
|
|
// Verify connection was NOT closed
|
|
if ws.close_code != 0 {
|
|
println(' Iteration ${i}: Connection should not be closed for valid message')
|
|
return false
|
|
}
|
|
|
|
// Verify connection state is still open
|
|
if ws.ready_state != .open {
|
|
println(' Iteration ${i}: Connection state should remain open')
|
|
return false
|
|
}
|
|
|
|
// Verify on_error was NOT invoked
|
|
if ws.error_messages.len > 0 {
|
|
println(' Iteration ${i}: on_error should not be invoked for valid message')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
|
|
fn test_property_8_binary_messages_validated() bool {
|
|
rand.seed([u32(time.now().unix()), u32(99999)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
// Generate random max_message_size
|
|
max_size := rand.int_in_range(100, 5000) or { 500 }
|
|
|
|
// Randomly decide if message should be oversized or valid
|
|
is_oversized := rand.int_in_range(0, 2) or { 0 } == 1
|
|
|
|
payload_size := if is_oversized {
|
|
max_size + rand.int_in_range(1, 500) or { 100 }
|
|
} else {
|
|
rand.int_in_range(0, max_size) or { 50 }
|
|
}
|
|
|
|
payload := generate_random_payload(payload_size)
|
|
|
|
// Create a binary frame
|
|
frame := create_test_frame(ws_opcode_binary, payload)
|
|
|
|
// Create test context
|
|
mut ws := create_test_ws_context()
|
|
|
|
events := WSEvents{
|
|
on_error: fn (err string, mut ws WSContext) {
|
|
ws.error_messages << err
|
|
}
|
|
}
|
|
|
|
// Validate message size
|
|
result := validate_message_size(frame, max_size, events, mut ws)
|
|
|
|
// Verify result matches expectation
|
|
if is_oversized {
|
|
if result {
|
|
println(' Iteration ${i}: Expected false for oversized binary message')
|
|
return false
|
|
}
|
|
if ws.close_code != ws_close_message_too_big {
|
|
println(' Iteration ${i}: Expected close code 1009 for oversized binary')
|
|
return false
|
|
}
|
|
} else {
|
|
if !result {
|
|
println(' Iteration ${i}: Expected true for valid binary message')
|
|
return false
|
|
}
|
|
if ws.close_code != 0 {
|
|
println(' Iteration ${i}: Should not close for valid binary message')
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
fn test_property_8_control_frames_not_validated() bool {
|
|
rand.seed([u32(time.now().unix()), u32(77777)])
|
|
|
|
// Control frames (ping, pong, close) should not be subject to message size validation
|
|
control_opcodes := [ws_opcode_ping, ws_opcode_pong, ws_opcode_close]
|
|
|
|
for i in 0 .. test_iterations {
|
|
// Use a very small max_message_size
|
|
max_size := 10
|
|
|
|
// Generate payload larger than max_size
|
|
payload := generate_random_payload(50)
|
|
|
|
// Pick a random control opcode
|
|
opcode := control_opcodes[rand.int_in_range(0, control_opcodes.len) or { 0 }]
|
|
|
|
// Create a control frame with "oversized" payload
|
|
frame := create_test_frame(opcode, payload)
|
|
|
|
// Create test context
|
|
mut ws := create_test_ws_context()
|
|
|
|
events := WSEvents{
|
|
on_error: fn (err string, mut ws WSContext) {
|
|
ws.error_messages << err
|
|
}
|
|
}
|
|
|
|
// Validate message size - should pass for control frames
|
|
result := validate_message_size(frame, max_size, events, mut ws)
|
|
|
|
// Control frames should always pass validation
|
|
if !result {
|
|
println(' Iteration ${i}: Control frame (opcode 0x${opcode:02x}) should not be size-validated')
|
|
return false
|
|
}
|
|
|
|
// Connection should remain open
|
|
if ws.ready_state != .open {
|
|
println(' Iteration ${i}: Connection should remain open for control frames')
|
|
return false
|
|
}
|
|
|
|
// No error should be raised
|
|
if ws.error_messages.len > 0 {
|
|
println(' Iteration ${i}: No error should be raised for control frames')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
fn test_property_8_exact_boundary() bool {
|
|
rand.seed([u32(time.now().unix()), u32(11111)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
// Generate random max_message_size
|
|
max_size := rand.int_in_range(100, 5000) or { 500 }
|
|
|
|
// Test exact boundary - message exactly at max_size should be accepted
|
|
exact_payload := generate_random_payload(max_size)
|
|
frame := create_test_frame(ws_opcode_text, exact_payload)
|
|
|
|
mut ws := create_test_ws_context()
|
|
events := WSEvents{}
|
|
|
|
result := validate_message_size(frame, max_size, events, mut ws)
|
|
|
|
// Exact boundary should be accepted
|
|
if !result {
|
|
println(' Iteration ${i}: Message at exact boundary (${max_size}) should be accepted')
|
|
return false
|
|
}
|
|
|
|
// Test one byte over - should be rejected
|
|
over_payload := generate_random_payload(max_size + 1)
|
|
over_frame := create_test_frame(ws_opcode_text, over_payload)
|
|
|
|
mut ws2 := create_test_ws_context()
|
|
result2 := validate_message_size(over_frame, max_size, events, mut ws2)
|
|
|
|
if result2 {
|
|
println(' Iteration ${i}: Message one byte over (${max_size + 1}) should be rejected')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
fn test_property_8_zero_max_size_disables_validation() bool {
|
|
rand.seed([u32(time.now().unix()), u32(22222)])
|
|
|
|
for i in 0 .. test_iterations {
|
|
// Generate a large payload
|
|
payload_size := rand.int_in_range(1000, 100000) or { 10000 }
|
|
payload := generate_random_payload(payload_size)
|
|
|
|
frame := create_test_frame(ws_opcode_text, payload)
|
|
|
|
mut ws := create_test_ws_context()
|
|
events := WSEvents{}
|
|
|
|
// With max_size = 0, validation should be disabled
|
|
result := validate_message_size(frame, 0, events, mut ws)
|
|
|
|
if !result {
|
|
println(' Iteration ${i}: With max_size=0, all messages should be accepted')
|
|
return false
|
|
}
|
|
|
|
if ws.ready_state != .open {
|
|
println(' Iteration ${i}: Connection should remain open when validation disabled')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
|
|
fn main() {
|
|
println('🚀 Starting WebSocket Message Size Limit Property Tests...')
|
|
println('Each property test runs ${test_iterations} iterations\n')
|
|
|
|
mut stats := PropertyTestStats{}
|
|
|
|
// Run property tests
|
|
// Feature: websocket-helper, Property 8: Message Size Limit
|
|
// Validates: Requirements 4.2
|
|
stats.run_property_test('Property 8: Oversized Messages Rejected', test_property_8_oversized_messages_rejected)
|
|
stats.run_property_test('Property 8: Valid Messages Accepted', test_property_8_valid_messages_accepted)
|
|
stats.run_property_test('Property 8: Binary Messages Validated', test_property_8_binary_messages_validated)
|
|
stats.run_property_test('Property 8: Control Frames Not Validated', test_property_8_control_frames_not_validated)
|
|
stats.run_property_test('Property 8: Exact Boundary', test_property_8_exact_boundary)
|
|
stats.run_property_test('Property 8: Zero Max Size Disables Validation', test_property_8_zero_max_size_disables_validation)
|
|
|
|
// Print test summary
|
|
stats.print_summary()
|
|
}
|