feat(websocket): add WebSocket Helper with RFC 6455 compliance
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
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
// test_websocket_close_handshake.v - Property test for WebSocket close handshake
|
||||
// Feature: websocket-helper, Property 6: Close Handshake
|
||||
// Validates: Requirements 3.3, 7.5
|
||||
//
|
||||
// *For any* call to `ws.close(code, reason)`, the server SHALL send a close frame
|
||||
// with the specified status code and reason, and transition the connection state to `closing`.
|
||||
module main
|
||||
|
||||
import rand
|
||||
import time
|
||||
|
||||
const test_iterations = 100
|
||||
|
||||
// WebSocket opcodes (RFC 6455)
|
||||
const ws_opcode_close = u8(0x8)
|
||||
|
||||
// WebSocket close codes (RFC 6455)
|
||||
const ws_close_normal = 1000
|
||||
const ws_close_going_away = 1001
|
||||
const ws_close_protocol_error = 1002
|
||||
const ws_close_unsupported_data = 1003
|
||||
const ws_close_invalid_payload = 1007
|
||||
const ws_close_policy_violation = 1008
|
||||
const ws_close_message_too_big = 1009
|
||||
const ws_close_internal_error = 1011
|
||||
|
||||
// WebSocket Ready State
|
||||
enum WSReadyState {
|
||||
connecting = 0
|
||||
open = 1
|
||||
closing = 2
|
||||
closed = 3
|
||||
}
|
||||
|
||||
struct WSCloseEvent {
|
||||
code int
|
||||
reason string
|
||||
was_clean bool
|
||||
}
|
||||
|
||||
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 Close Handshake 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')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Close Frame Functions (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 build_close_frame(code int, reason string) []u8 {
|
||||
mut payload := []u8{cap: 2 + reason.len}
|
||||
payload << u8(code >> 8)
|
||||
payload << u8(code & 0xFF)
|
||||
if reason.len > 0 {
|
||||
payload << reason.bytes()
|
||||
}
|
||||
|
||||
return encode_ws_frame(ws_opcode_close, payload, false)
|
||||
}
|
||||
|
||||
fn parse_close_frame(payload []u8) (int, string) {
|
||||
mut code := ws_close_normal
|
||||
mut reason := ''
|
||||
|
||||
if payload.len >= 2 {
|
||||
code = int(payload[0]) << 8 | int(payload[1])
|
||||
if payload.len > 2 {
|
||||
reason = payload[2..].bytestr()
|
||||
}
|
||||
}
|
||||
|
||||
return code, reason
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn is_valid_close_code(code int) bool {
|
||||
if code < 1000 {
|
||||
return false
|
||||
}
|
||||
if code >= 1004 && code <= 1006 {
|
||||
return false
|
||||
}
|
||||
if code >= 1012 && code <= 2999 {
|
||||
return false
|
||||
}
|
||||
if code > 4999 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock WSContext for testing
|
||||
// ============================================================================
|
||||
|
||||
struct MockWSContext {
|
||||
mut:
|
||||
ready_state WSReadyState
|
||||
sent_frames [][]u8
|
||||
close_called bool
|
||||
close_code int
|
||||
close_reason string
|
||||
}
|
||||
|
||||
fn create_mock_ws_context() MockWSContext {
|
||||
return MockWSContext{
|
||||
ready_state: .open
|
||||
sent_frames: [][]u8{}
|
||||
close_called: false
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut ws MockWSContext) close(code int, reason string) {
|
||||
if ws.ready_state == .closed || ws.ready_state == .closing {
|
||||
return
|
||||
}
|
||||
|
||||
ws.ready_state = .closing
|
||||
ws.close_called = true
|
||||
ws.close_code = code
|
||||
ws.close_reason = reason
|
||||
|
||||
// Build and "send" close frame
|
||||
close_frame := build_close_frame(code, reason)
|
||||
ws.sent_frames << close_frame
|
||||
}
|
||||
|
||||
fn (mut ws MockWSContext) handle_close_frame(payload []u8) WSCloseEvent {
|
||||
code, reason := parse_close_frame(payload)
|
||||
was_clean := is_valid_close_code(code)
|
||||
|
||||
if ws.ready_state == .open {
|
||||
ws.ready_state = .closing
|
||||
close_frame := build_close_frame(code, reason)
|
||||
ws.sent_frames << close_frame
|
||||
}
|
||||
|
||||
ws.ready_state = .closed
|
||||
|
||||
return WSCloseEvent{
|
||||
code: code
|
||||
reason: reason
|
||||
was_clean: was_clean
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
fn get_valid_close_codes() []int {
|
||||
return [
|
||||
ws_close_normal,
|
||||
ws_close_going_away,
|
||||
ws_close_protocol_error,
|
||||
ws_close_unsupported_data,
|
||||
ws_close_invalid_payload,
|
||||
ws_close_policy_violation,
|
||||
ws_close_message_too_big,
|
||||
ws_close_internal_error,
|
||||
3000, 3500, 3999, // Library/framework codes
|
||||
4000, 4500, 4999, // Private use codes
|
||||
]
|
||||
}
|
||||
|
||||
fn generate_random_close_code() int {
|
||||
codes := get_valid_close_codes()
|
||||
idx := rand.int_in_range(0, codes.len) or { 0 }
|
||||
return codes[idx]
|
||||
}
|
||||
|
||||
fn generate_random_reason() string {
|
||||
reasons := [
|
||||
'',
|
||||
'Normal closure',
|
||||
'Server shutting down',
|
||||
'Protocol error detected',
|
||||
'Invalid data received',
|
||||
'Connection timeout',
|
||||
'User disconnected',
|
||||
'Session expired',
|
||||
]
|
||||
idx := rand.int_in_range(0, reasons.len) or { 0 }
|
||||
return reasons[idx]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 6: Close Handshake
|
||||
// Feature: websocket-helper, Property 6: Close Handshake
|
||||
// Validates: Requirements 3.3, 7.5
|
||||
// ============================================================================
|
||||
|
||||
fn test_property_6_close_sends_frame_with_code_and_reason() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
code := generate_random_close_code()
|
||||
reason := generate_random_reason()
|
||||
|
||||
mut ws := create_mock_ws_context()
|
||||
ws.close(code, reason)
|
||||
|
||||
// Verify a close frame was sent
|
||||
if ws.sent_frames.len != 1 {
|
||||
println(' Iteration ${i}: Expected 1 sent frame, got ${ws.sent_frames.len}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Decode the sent frame
|
||||
fin, opcode, payload := decode_ws_frame(ws.sent_frames[0]) or {
|
||||
println(' Iteration ${i}: Failed to decode close frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify it's a close frame
|
||||
if opcode != ws_opcode_close {
|
||||
println(' Iteration ${i}: Expected close opcode (0x8), got 0x${opcode:02x}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify FIN bit is set
|
||||
if !fin {
|
||||
println(' Iteration ${i}: FIN bit should be set for close frame')
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse the close frame payload
|
||||
parsed_code, parsed_reason := parse_close_frame(payload)
|
||||
|
||||
// Verify code matches
|
||||
if parsed_code != code {
|
||||
println(' Iteration ${i}: Expected code ${code}, got ${parsed_code}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify reason matches
|
||||
if parsed_reason != reason {
|
||||
println(' Iteration ${i}: Expected reason "${reason}", got "${parsed_reason}"')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_6_close_transitions_to_closing_state() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(54321)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
code := generate_random_close_code()
|
||||
reason := generate_random_reason()
|
||||
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// Verify initial state is open
|
||||
if ws.ready_state != .open {
|
||||
println(' Iteration ${i}: Initial state should be open')
|
||||
return false
|
||||
}
|
||||
|
||||
ws.close(code, reason)
|
||||
|
||||
// Verify state transitioned to closing
|
||||
if ws.ready_state != .closing {
|
||||
println(' Iteration ${i}: State should be closing after close(), got ${ws.ready_state}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_6_close_is_idempotent() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(99999)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
code := generate_random_close_code()
|
||||
reason := generate_random_reason()
|
||||
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// First close
|
||||
ws.close(code, reason)
|
||||
frames_after_first := ws.sent_frames.len
|
||||
state_after_first := ws.ready_state
|
||||
|
||||
// Second close (should be no-op)
|
||||
ws.close(code, reason)
|
||||
|
||||
// Verify no additional frames were sent
|
||||
if ws.sent_frames.len != frames_after_first {
|
||||
println(' Iteration ${i}: Second close should not send additional frames')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify state didn't change
|
||||
if ws.ready_state != state_after_first {
|
||||
println(' Iteration ${i}: State should remain ${state_after_first} after second close')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_6_handle_incoming_close_frame() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(77777)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
code := generate_random_close_code()
|
||||
reason := generate_random_reason()
|
||||
|
||||
// Build incoming close frame payload
|
||||
mut payload := []u8{cap: 2 + reason.len}
|
||||
payload << u8(code >> 8)
|
||||
payload << u8(code & 0xFF)
|
||||
if reason.len > 0 {
|
||||
payload << reason.bytes()
|
||||
}
|
||||
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// Handle incoming close frame
|
||||
close_event := ws.handle_close_frame(payload)
|
||||
|
||||
// Verify close event has correct code
|
||||
if close_event.code != code {
|
||||
println(' Iteration ${i}: Close event code should be ${code}, got ${close_event.code}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify close event has correct reason
|
||||
if close_event.reason != reason {
|
||||
println(' Iteration ${i}: Close event reason should be "${reason}", got "${close_event.reason}"')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify was_clean is correct
|
||||
expected_clean := is_valid_close_code(code)
|
||||
if close_event.was_clean != expected_clean {
|
||||
println(' Iteration ${i}: was_clean should be ${expected_clean}, got ${close_event.was_clean}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify state is closed
|
||||
if ws.ready_state != .closed {
|
||||
println(' Iteration ${i}: State should be closed after handling close frame')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify echo close frame was sent
|
||||
if ws.sent_frames.len != 1 {
|
||||
println(' Iteration ${i}: Should send echo close frame')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_6_valid_close_codes() bool {
|
||||
// Test all standard valid close codes
|
||||
valid_codes := [
|
||||
ws_close_normal,
|
||||
ws_close_going_away,
|
||||
ws_close_protocol_error,
|
||||
ws_close_unsupported_data,
|
||||
ws_close_invalid_payload,
|
||||
ws_close_policy_violation,
|
||||
ws_close_message_too_big,
|
||||
ws_close_internal_error,
|
||||
]
|
||||
|
||||
for code in valid_codes {
|
||||
if !is_valid_close_code(code) {
|
||||
println(' Standard code ${code} should be valid')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Test library/framework codes (3000-3999)
|
||||
for code in [3000, 3500, 3999] {
|
||||
if !is_valid_close_code(code) {
|
||||
println(' Library code ${code} should be valid')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Test private use codes (4000-4999)
|
||||
for code in [4000, 4500, 4999] {
|
||||
if !is_valid_close_code(code) {
|
||||
println(' Private code ${code} should be valid')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Test invalid codes
|
||||
invalid_codes := [0, 999, 1004, 1005, 1006, 1012, 2000, 2999, 5000, 6000]
|
||||
for code in invalid_codes {
|
||||
if is_valid_close_code(code) {
|
||||
println(' Code ${code} should be invalid')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_close_frame_round_trip() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(55555)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
code := generate_random_close_code()
|
||||
reason := generate_random_reason()
|
||||
|
||||
// Build close frame
|
||||
frame := build_close_frame(code, reason)
|
||||
|
||||
// Decode frame
|
||||
_, opcode, payload := decode_ws_frame(frame) or {
|
||||
println(' Iteration ${i}: Failed to decode frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
if opcode != ws_opcode_close {
|
||||
println(' Iteration ${i}: Expected close opcode')
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse payload
|
||||
parsed_code, parsed_reason := parse_close_frame(payload)
|
||||
|
||||
// Verify round-trip
|
||||
if parsed_code != code {
|
||||
println(' Iteration ${i}: Code mismatch: ${code} vs ${parsed_code}')
|
||||
return false
|
||||
}
|
||||
if parsed_reason != reason {
|
||||
println(' Iteration ${i}: Reason mismatch: "${reason}" vs "${parsed_reason}"')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket Close Handshake Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 6: Close Handshake
|
||||
// Validates: Requirements 3.3, 7.5
|
||||
stats.run_property_test('Property 6: Close Sends Frame with Code and Reason', test_property_6_close_sends_frame_with_code_and_reason)
|
||||
stats.run_property_test('Property 6: Close Transitions to Closing State', test_property_6_close_transitions_to_closing_state)
|
||||
stats.run_property_test('Property 6: Close is Idempotent', test_property_6_close_is_idempotent)
|
||||
stats.run_property_test('Property 6: Handle Incoming Close Frame', test_property_6_handle_incoming_close_frame)
|
||||
stats.run_property_test('Property 6: Valid Close Codes', test_property_6_valid_close_codes)
|
||||
stats.run_property_test('Close Frame Round Trip', test_close_frame_round_trip)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
// test_websocket_connection_state.v - Property test for WebSocket connection state tracking
|
||||
// Feature: websocket-helper, Property 7: Connection State Tracking
|
||||
// Validates: Requirements 3.5
|
||||
//
|
||||
// *For any* WSContext, the `ready_state` property SHALL accurately reflect the current
|
||||
// connection state: `connecting` during handshake, `open` after successful handshake,
|
||||
// `closing` after close initiated, and `closed` after connection terminated.
|
||||
module main
|
||||
|
||||
import rand
|
||||
import time
|
||||
|
||||
const test_iterations = 100
|
||||
|
||||
// WebSocket Ready State (copied from websocket.v for testing)
|
||||
enum WSReadyState {
|
||||
connecting = 0
|
||||
open = 1
|
||||
closing = 2
|
||||
closed = 3
|
||||
}
|
||||
|
||||
// State change event for tracking
|
||||
struct WSStateChangeEvent {
|
||||
previous_state WSReadyState
|
||||
new_state WSReadyState
|
||||
timestamp i64
|
||||
}
|
||||
|
||||
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 Connection State 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')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State Transition Validation (copied from websocket.v for testing)
|
||||
// ============================================================================
|
||||
|
||||
// Valid state transitions according to WebSocket protocol:
|
||||
// connecting -> open (handshake successful)
|
||||
// connecting -> closed (handshake failed)
|
||||
// open -> closing (close initiated)
|
||||
// open -> closed (abrupt close)
|
||||
// closing -> closed (close handshake complete)
|
||||
|
||||
fn is_valid_state_transition(from WSReadyState, to WSReadyState) bool {
|
||||
match from {
|
||||
.connecting {
|
||||
return to == .open || to == .closed
|
||||
}
|
||||
.open {
|
||||
return to == .closing || to == .closed
|
||||
}
|
||||
.closing {
|
||||
return to == .closed
|
||||
}
|
||||
.closed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock WSContext for testing state transitions
|
||||
// ============================================================================
|
||||
|
||||
struct MockWSContext {
|
||||
mut:
|
||||
ready_state WSReadyState
|
||||
state_history []WSStateChangeEvent
|
||||
}
|
||||
|
||||
fn create_mock_ws_context() MockWSContext {
|
||||
return MockWSContext{
|
||||
ready_state: .connecting
|
||||
state_history: []WSStateChangeEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut ws MockWSContext) transition_state(new_state WSReadyState) bool {
|
||||
old_state := ws.ready_state
|
||||
|
||||
if !is_valid_state_transition(old_state, new_state) {
|
||||
return false
|
||||
}
|
||||
|
||||
ws.ready_state = new_state
|
||||
ws.state_history << WSStateChangeEvent{
|
||||
previous_state: old_state
|
||||
new_state: new_state
|
||||
timestamp: time.now().unix()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn (ws MockWSContext) is_open() bool {
|
||||
return ws.ready_state == .open
|
||||
}
|
||||
|
||||
fn (ws MockWSContext) is_closed() bool {
|
||||
return ws.ready_state == .closed || ws.ready_state == .closing
|
||||
}
|
||||
|
||||
fn (ws MockWSContext) can_send() bool {
|
||||
return ws.ready_state == .open
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random State Generators
|
||||
// ============================================================================
|
||||
|
||||
fn get_all_states() []WSReadyState {
|
||||
return [WSReadyState.connecting, .open, .closing, .closed]
|
||||
}
|
||||
|
||||
fn generate_random_state() WSReadyState {
|
||||
states := get_all_states()
|
||||
idx := rand.int_in_range(0, states.len) or { 0 }
|
||||
return states[idx]
|
||||
}
|
||||
|
||||
fn generate_valid_transition_sequence() []WSReadyState {
|
||||
// Generate a valid sequence of state transitions
|
||||
mut sequence := []WSReadyState{}
|
||||
mut current := WSReadyState.connecting
|
||||
sequence << current
|
||||
|
||||
// Randomly decide the path
|
||||
choice := rand.int_in_range(0, 4) or { 0 }
|
||||
|
||||
match choice {
|
||||
0 {
|
||||
// connecting -> open -> closing -> closed
|
||||
sequence << WSReadyState.open
|
||||
sequence << WSReadyState.closing
|
||||
sequence << WSReadyState.closed
|
||||
}
|
||||
1 {
|
||||
// connecting -> open -> closed (abrupt)
|
||||
sequence << WSReadyState.open
|
||||
sequence << WSReadyState.closed
|
||||
}
|
||||
2 {
|
||||
// connecting -> closed (handshake failed)
|
||||
sequence << WSReadyState.closed
|
||||
}
|
||||
else {
|
||||
// connecting -> open (stay open)
|
||||
sequence << WSReadyState.open
|
||||
}
|
||||
}
|
||||
|
||||
return sequence
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 7: Connection State Tracking
|
||||
// Feature: websocket-helper, Property 7: Connection State Tracking
|
||||
// Validates: Requirements 3.5
|
||||
//
|
||||
// *For any* WSContext, the `ready_state` property SHALL accurately reflect
|
||||
// the current connection state.
|
||||
// ============================================================================
|
||||
|
||||
fn test_property_7_initial_state_is_connecting() bool {
|
||||
// All new connections should start in connecting state
|
||||
for _ in 0 .. test_iterations {
|
||||
ws := create_mock_ws_context()
|
||||
|
||||
if ws.ready_state != .connecting {
|
||||
println(' New connection should start in connecting state')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_7_valid_transitions_succeed() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
sequence := generate_valid_transition_sequence()
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// Apply all transitions in sequence (skip first as it's initial state)
|
||||
for j := 1; j < sequence.len; j++ {
|
||||
target_state := sequence[j]
|
||||
success := ws.transition_state(target_state)
|
||||
|
||||
if !success {
|
||||
println(' Iteration ${i}: Valid transition from ${sequence[j-1]} to ${target_state} should succeed')
|
||||
return false
|
||||
}
|
||||
|
||||
if ws.ready_state != target_state {
|
||||
println(' Iteration ${i}: State should be ${target_state} after transition, got ${ws.ready_state}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Verify history was recorded
|
||||
if ws.state_history.len != sequence.len - 1 {
|
||||
println(' Iteration ${i}: State history should have ${sequence.len - 1} entries, got ${ws.state_history.len}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_7_invalid_transitions_fail() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(54321)])
|
||||
|
||||
// Test invalid transitions
|
||||
invalid_transitions := [
|
||||
// From closed, no transitions are valid
|
||||
[WSReadyState.closed, WSReadyState.connecting],
|
||||
[WSReadyState.closed, WSReadyState.open],
|
||||
[WSReadyState.closed, WSReadyState.closing],
|
||||
// From closing, can only go to closed
|
||||
[WSReadyState.closing, WSReadyState.connecting],
|
||||
[WSReadyState.closing, WSReadyState.open],
|
||||
// From connecting, cannot go to closing
|
||||
[WSReadyState.connecting, WSReadyState.closing],
|
||||
// From open, cannot go back to connecting
|
||||
[WSReadyState.open, WSReadyState.connecting],
|
||||
]
|
||||
|
||||
for transition in invalid_transitions {
|
||||
from_state := transition[0]
|
||||
to_state := transition[1]
|
||||
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// Set up initial state (may need intermediate transitions)
|
||||
if from_state == .open {
|
||||
ws.transition_state(.open)
|
||||
} else if from_state == .closing {
|
||||
ws.transition_state(.open)
|
||||
ws.transition_state(.closing)
|
||||
} else if from_state == .closed {
|
||||
ws.transition_state(.closed)
|
||||
}
|
||||
|
||||
// Try invalid transition
|
||||
old_state := ws.ready_state
|
||||
success := ws.transition_state(to_state)
|
||||
|
||||
if success {
|
||||
println(' Invalid transition from ${from_state} to ${to_state} should fail')
|
||||
return false
|
||||
}
|
||||
|
||||
// State should remain unchanged
|
||||
if ws.ready_state != old_state {
|
||||
println(' State should remain ${old_state} after failed transition, got ${ws.ready_state}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_7_state_reflects_connection_lifecycle() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(99999)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// Initial: connecting state
|
||||
if ws.ready_state != .connecting {
|
||||
println(' Iteration ${i}: Initial state should be connecting')
|
||||
return false
|
||||
}
|
||||
if ws.is_open() {
|
||||
println(' Iteration ${i}: is_open() should be false in connecting state')
|
||||
return false
|
||||
}
|
||||
if ws.can_send() {
|
||||
println(' Iteration ${i}: can_send() should be false in connecting state')
|
||||
return false
|
||||
}
|
||||
|
||||
// Handshake success: open state
|
||||
ws.transition_state(.open)
|
||||
if ws.ready_state != .open {
|
||||
println(' Iteration ${i}: State should be open after handshake')
|
||||
return false
|
||||
}
|
||||
if !ws.is_open() {
|
||||
println(' Iteration ${i}: is_open() should be true in open state')
|
||||
return false
|
||||
}
|
||||
if !ws.can_send() {
|
||||
println(' Iteration ${i}: can_send() should be true in open state')
|
||||
return false
|
||||
}
|
||||
|
||||
// Close initiated: closing state
|
||||
ws.transition_state(.closing)
|
||||
if ws.ready_state != .closing {
|
||||
println(' Iteration ${i}: State should be closing after close initiated')
|
||||
return false
|
||||
}
|
||||
if ws.is_open() {
|
||||
println(' Iteration ${i}: is_open() should be false in closing state')
|
||||
return false
|
||||
}
|
||||
if ws.can_send() {
|
||||
println(' Iteration ${i}: can_send() should be false in closing state')
|
||||
return false
|
||||
}
|
||||
if !ws.is_closed() {
|
||||
println(' Iteration ${i}: is_closed() should be true in closing state')
|
||||
return false
|
||||
}
|
||||
|
||||
// Connection terminated: closed state
|
||||
ws.transition_state(.closed)
|
||||
if ws.ready_state != .closed {
|
||||
println(' Iteration ${i}: State should be closed after termination')
|
||||
return false
|
||||
}
|
||||
if ws.is_open() {
|
||||
println(' Iteration ${i}: is_open() should be false in closed state')
|
||||
return false
|
||||
}
|
||||
if ws.can_send() {
|
||||
println(' Iteration ${i}: can_send() should be false in closed state')
|
||||
return false
|
||||
}
|
||||
if !ws.is_closed() {
|
||||
println(' Iteration ${i}: is_closed() should be true in closed state')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_7_state_history_tracking() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(77777)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
sequence := generate_valid_transition_sequence()
|
||||
mut ws := create_mock_ws_context()
|
||||
|
||||
// Apply transitions
|
||||
for j := 1; j < sequence.len; j++ {
|
||||
ws.transition_state(sequence[j])
|
||||
}
|
||||
|
||||
// Verify history
|
||||
for j, event in ws.state_history {
|
||||
expected_from := sequence[j]
|
||||
expected_to := sequence[j + 1]
|
||||
|
||||
if event.previous_state != expected_from {
|
||||
println(' Iteration ${i}: History[${j}] previous_state should be ${expected_from}, got ${event.previous_state}')
|
||||
return false
|
||||
}
|
||||
if event.new_state != expected_to {
|
||||
println(' Iteration ${i}: History[${j}] new_state should be ${expected_to}, got ${event.new_state}')
|
||||
return false
|
||||
}
|
||||
if event.timestamp <= 0 {
|
||||
println(' Iteration ${i}: History[${j}] should have valid timestamp')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket Connection State Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 7: Connection State Tracking
|
||||
// Validates: Requirements 3.5
|
||||
stats.run_property_test('Property 7: Initial State is Connecting', test_property_7_initial_state_is_connecting)
|
||||
stats.run_property_test('Property 7: Valid Transitions Succeed', test_property_7_valid_transitions_succeed)
|
||||
stats.run_property_test('Property 7: Invalid Transitions Fail', test_property_7_invalid_transitions_fail)
|
||||
stats.run_property_test('Property 7: State Reflects Connection Lifecycle', test_property_7_state_reflects_connection_lifecycle)
|
||||
stats.run_property_test('Property 7: State History Tracking', test_property_7_state_history_tracking)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
// test_websocket_context_preservation.v - Property test for WebSocket context preservation
|
||||
// Feature: websocket-helper, Property 10: Context Preservation
|
||||
// Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5
|
||||
//
|
||||
// *For any* WebSocket handler, the WSContext SHALL provide access to the original
|
||||
// HTTP context including route parameters, query parameters, and middleware store
|
||||
// values that were set before the upgrade.
|
||||
module main
|
||||
|
||||
import rand
|
||||
import time
|
||||
import net.http
|
||||
|
||||
const test_iterations = 100
|
||||
|
||||
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 Context Preservation 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')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Minimal type definitions for testing (to avoid import issues)
|
||||
// ============================================================================
|
||||
|
||||
// WSReadyState - WebSocket connection state enumeration
|
||||
enum WSReadyState {
|
||||
connecting = 0
|
||||
open = 1
|
||||
closing = 2
|
||||
closed = 3
|
||||
}
|
||||
|
||||
// Context - Minimal HTTP context for testing
|
||||
@[heap]
|
||||
struct Context {
|
||||
pub:
|
||||
req http.Request
|
||||
params map[string]string
|
||||
query map[string]string
|
||||
url string
|
||||
path string
|
||||
pub mut:
|
||||
status_code int = 200
|
||||
headers map[string]string
|
||||
body string
|
||||
store map[string]string
|
||||
}
|
||||
|
||||
// WSContext - WebSocket context for connection operations
|
||||
struct WSContext {
|
||||
pub:
|
||||
http_ctx &Context
|
||||
params map[string]string
|
||||
query map[string]string
|
||||
store map[string]string
|
||||
pub mut:
|
||||
ready_state WSReadyState
|
||||
protocol string
|
||||
socket voidptr
|
||||
}
|
||||
|
||||
// create_ws_context - Create a WSContext from an HTTP Context
|
||||
fn create_ws_context(c &Context, protocol string) WSContext {
|
||||
return WSContext{
|
||||
http_ctx: c
|
||||
params: c.params.clone()
|
||||
query: c.query.clone()
|
||||
store: c.store.clone()
|
||||
ready_state: .connecting
|
||||
protocol: protocol
|
||||
socket: unsafe { nil }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
fn generate_random_string(min_len int, max_len int) string {
|
||||
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'
|
||||
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
|
||||
mut result := ''
|
||||
for _ in 0 .. len {
|
||||
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||
result += chars[idx].ascii_str()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fn generate_random_params() map[string]string {
|
||||
mut params := map[string]string{}
|
||||
num_params := rand.int_in_range(0, 6) or { 0 }
|
||||
for _ in 0 .. num_params {
|
||||
key := generate_random_string(3, 10)
|
||||
value := generate_random_string(1, 20)
|
||||
params[key] = value
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
fn generate_random_query() map[string]string {
|
||||
mut query := map[string]string{}
|
||||
num_params := rand.int_in_range(0, 6) or { 0 }
|
||||
for _ in 0 .. num_params {
|
||||
key := generate_random_string(3, 10)
|
||||
value := generate_random_string(1, 20)
|
||||
query[key] = value
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
fn generate_random_store() map[string]string {
|
||||
mut store := map[string]string{}
|
||||
num_items := rand.int_in_range(0, 6) or { 0 }
|
||||
for _ in 0 .. num_items {
|
||||
key := generate_random_string(3, 15)
|
||||
value := generate_random_string(1, 30)
|
||||
store[key] = value
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
fn create_test_context(params map[string]string, query map[string]string, store map[string]string) Context {
|
||||
// Build query string
|
||||
mut query_parts := []string{}
|
||||
for k, v in query {
|
||||
query_parts << '${k}=${v}'
|
||||
}
|
||||
query_string := if query_parts.len > 0 { '?' + query_parts.join('&') } else { '' }
|
||||
|
||||
url := '/ws/test${query_string}'
|
||||
|
||||
mut ctx := Context{
|
||||
req: http.Request{
|
||||
method: .get
|
||||
url: url
|
||||
}
|
||||
params: params
|
||||
query: query
|
||||
url: url
|
||||
path: '/ws/test'
|
||||
store: store
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 10: Context Preservation
|
||||
// Feature: websocket-helper, Property 10: Context Preservation
|
||||
// Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5
|
||||
//
|
||||
// *For any* WebSocket handler, the WSContext SHALL provide access to the original
|
||||
// HTTP context including route parameters, query parameters, and middleware store
|
||||
// values that were set before the upgrade.
|
||||
// ============================================================================
|
||||
|
||||
// Test that route parameters are preserved
|
||||
fn test_property_10_params_preserved() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate random route parameters
|
||||
params := generate_random_params()
|
||||
query := generate_random_query()
|
||||
store := generate_random_store()
|
||||
|
||||
// Create HTTP context
|
||||
ctx := create_test_context(params, query, store)
|
||||
|
||||
// Create WebSocket context
|
||||
ws_ctx := create_ws_context(&ctx, '')
|
||||
|
||||
// Verify all params are preserved
|
||||
if ws_ctx.params.len != params.len {
|
||||
println(' Iteration ${i}: Params count mismatch')
|
||||
println(' Expected: ${params.len}, Got: ${ws_ctx.params.len}')
|
||||
return false
|
||||
}
|
||||
|
||||
for key, expected_value in params {
|
||||
if key !in ws_ctx.params {
|
||||
println(' Iteration ${i}: Missing param key: ${key}')
|
||||
return false
|
||||
}
|
||||
if ws_ctx.params[key] != expected_value {
|
||||
println(' Iteration ${i}: Param value mismatch for key ${key}')
|
||||
println(' Expected: ${expected_value}, Got: ${ws_ctx.params[key]}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test that query parameters are preserved
|
||||
fn test_property_10_query_preserved() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(23456)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
params := generate_random_params()
|
||||
query := generate_random_query()
|
||||
store := generate_random_store()
|
||||
|
||||
ctx := create_test_context(params, query, store)
|
||||
ws_ctx := create_ws_context(&ctx, '')
|
||||
|
||||
// Verify all query params are preserved
|
||||
if ws_ctx.query.len != query.len {
|
||||
println(' Iteration ${i}: Query count mismatch')
|
||||
println(' Expected: ${query.len}, Got: ${ws_ctx.query.len}')
|
||||
return false
|
||||
}
|
||||
|
||||
for key, expected_value in query {
|
||||
if key !in ws_ctx.query {
|
||||
println(' Iteration ${i}: Missing query key: ${key}')
|
||||
return false
|
||||
}
|
||||
if ws_ctx.query[key] != expected_value {
|
||||
println(' Iteration ${i}: Query value mismatch for key ${key}')
|
||||
println(' Expected: ${expected_value}, Got: ${ws_ctx.query[key]}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test that middleware store values are preserved
|
||||
fn test_property_10_store_preserved() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(34567)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
params := generate_random_params()
|
||||
query := generate_random_query()
|
||||
store := generate_random_store()
|
||||
|
||||
ctx := create_test_context(params, query, store)
|
||||
ws_ctx := create_ws_context(&ctx, '')
|
||||
|
||||
// Verify all store values are preserved
|
||||
if ws_ctx.store.len != store.len {
|
||||
println(' Iteration ${i}: Store count mismatch')
|
||||
println(' Expected: ${store.len}, Got: ${ws_ctx.store.len}')
|
||||
return false
|
||||
}
|
||||
|
||||
for key, expected_value in store {
|
||||
if key !in ws_ctx.store {
|
||||
println(' Iteration ${i}: Missing store key: ${key}')
|
||||
return false
|
||||
}
|
||||
if ws_ctx.store[key] != expected_value {
|
||||
println(' Iteration ${i}: Store value mismatch for key ${key}')
|
||||
println(' Expected: ${expected_value}, Got: ${ws_ctx.store[key]}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test that HTTP context reference is preserved
|
||||
fn test_property_10_http_context_reference() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(45678)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
params := generate_random_params()
|
||||
query := generate_random_query()
|
||||
store := generate_random_store()
|
||||
|
||||
ctx := create_test_context(params, query, store)
|
||||
ws_ctx := create_ws_context(&ctx, '')
|
||||
|
||||
// Verify HTTP context reference is valid
|
||||
if ws_ctx.http_ctx == unsafe { nil } {
|
||||
println(' Iteration ${i}: HTTP context reference is nil')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify we can access original context data through the reference
|
||||
if ws_ctx.http_ctx.path != ctx.path {
|
||||
println(' Iteration ${i}: HTTP context path mismatch')
|
||||
println(' Expected: ${ctx.path}, Got: ${ws_ctx.http_ctx.path}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test that modifications to WSContext don't affect original context
|
||||
fn test_property_10_context_isolation() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(56789)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
params := generate_random_params()
|
||||
query := generate_random_query()
|
||||
store := generate_random_store()
|
||||
|
||||
// Save original values
|
||||
original_params := params.clone()
|
||||
|
||||
ctx := create_test_context(params, query, store)
|
||||
ws_ctx := create_ws_context(&ctx, '')
|
||||
|
||||
// Since WSContext fields are immutable (by design for safety),
|
||||
// we verify that the cloned maps are independent copies
|
||||
// by checking that the WSContext has its own copy of the data
|
||||
|
||||
// Verify WSContext has independent copies (not references to original)
|
||||
// The fact that params, query, store are cloned means modifications
|
||||
// to the original context after WSContext creation won't affect WSContext
|
||||
|
||||
// Verify original values are still intact in WSContext
|
||||
for key, value in original_params {
|
||||
if key !in ws_ctx.params || ws_ctx.params[key] != value {
|
||||
println(' Iteration ${i}: WSContext params value changed unexpectedly')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the maps are equal (proper cloning)
|
||||
if ws_ctx.params != params {
|
||||
println(' Iteration ${i}: Params not properly cloned')
|
||||
return false
|
||||
}
|
||||
if ws_ctx.query != query {
|
||||
println(' Iteration ${i}: Query not properly cloned')
|
||||
return false
|
||||
}
|
||||
if ws_ctx.store != store {
|
||||
println(' Iteration ${i}: Store not properly cloned')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test that protocol is correctly set
|
||||
fn test_property_10_protocol_preserved() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(67890)])
|
||||
|
||||
protocols := ['', 'chat', 'graphql-ws', 'mqtt', 'custom-protocol-v1']
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
params := generate_random_params()
|
||||
query := generate_random_query()
|
||||
store := generate_random_store()
|
||||
|
||||
// Pick a random protocol
|
||||
protocol_idx := rand.int_in_range(0, protocols.len) or { 0 }
|
||||
protocol := protocols[protocol_idx]
|
||||
|
||||
ctx := create_test_context(params, query, store)
|
||||
ws_ctx := create_ws_context(&ctx, protocol)
|
||||
|
||||
// Verify protocol is preserved
|
||||
if ws_ctx.protocol != protocol {
|
||||
println(' Iteration ${i}: Protocol mismatch')
|
||||
println(' Expected: ${protocol}, Got: ${ws_ctx.protocol}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test combined preservation of all context data
|
||||
fn test_property_10_combined_preservation() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(78901)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate random data
|
||||
params := generate_random_params()
|
||||
query := generate_random_query()
|
||||
store := generate_random_store()
|
||||
protocol := generate_random_string(5, 15)
|
||||
|
||||
ctx := create_test_context(params, query, store)
|
||||
ws_ctx := create_ws_context(&ctx, protocol)
|
||||
|
||||
// Verify all data is preserved together
|
||||
all_preserved := ws_ctx.params == params &&
|
||||
ws_ctx.query == query &&
|
||||
ws_ctx.store == store &&
|
||||
ws_ctx.protocol == protocol &&
|
||||
ws_ctx.http_ctx != unsafe { nil }
|
||||
|
||||
if !all_preserved {
|
||||
println(' Iteration ${i}: Combined preservation failed')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket Context Preservation Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 10: Context Preservation
|
||||
// Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5
|
||||
stats.run_property_test('Property 10.1: Route Params Preserved', test_property_10_params_preserved)
|
||||
stats.run_property_test('Property 10.2: Query Params Preserved', test_property_10_query_preserved)
|
||||
stats.run_property_test('Property 10.3: Store Values Preserved', test_property_10_store_preserved)
|
||||
stats.run_property_test('Property 10.4: HTTP Context Reference', test_property_10_http_context_reference)
|
||||
stats.run_property_test('Property 10.5: Context Isolation', test_property_10_context_isolation)
|
||||
stats.run_property_test('Property 10.6: Protocol Preserved', test_property_10_protocol_preserved)
|
||||
stats.run_property_test('Property 10.7: Combined Preservation', test_property_10_combined_preservation)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
// test_websocket_frame_encoding.v - Property test for WebSocket frame encoding
|
||||
// Feature: websocket-helper, Property 5: Frame Encoding
|
||||
// Validates: Requirements 3.1, 3.2, 7.3
|
||||
//
|
||||
// *For any* string data sent via ws.send(data), the resulting WebSocket frame
|
||||
// SHALL have opcode 0x1 (text) and contain the exact string bytes.
|
||||
// *For any* binary data sent via ws.send_bytes(data), the resulting frame
|
||||
// SHALL have opcode 0x2 (binary) and contain the exact byte sequence.
|
||||
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)
|
||||
|
||||
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 Frame Encoding 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 Encoding/Decoding (copied from websocket.v for testing)
|
||||
// ============================================================================
|
||||
|
||||
struct WSFrame {
|
||||
pub:
|
||||
fin bool
|
||||
opcode u8
|
||||
masked bool
|
||||
mask_key [4]u8
|
||||
payload_len u64
|
||||
payload []u8
|
||||
}
|
||||
|
||||
fn encode_ws_frame(opcode u8, payload []u8, masked bool) []u8 {
|
||||
payload_len := payload.len
|
||||
mut frame := []u8{cap: 14 + payload_len}
|
||||
|
||||
// First byte: FIN bit (1) + RSV (000) + opcode (4 bits)
|
||||
frame << u8(0x80 | (opcode & 0x0F))
|
||||
|
||||
// Second byte: MASK bit + payload length
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Add masking key and masked payload if masked
|
||||
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) !WSFrame {
|
||||
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 WSFrame{
|
||||
fin: fin
|
||||
opcode: opcode
|
||||
masked: masked
|
||||
mask_key: mask_key
|
||||
payload_len: payload_len
|
||||
payload: payload
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
fn generate_random_string() string {
|
||||
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
len := rand.int_in_range(0, 200) or { 50 }
|
||||
mut result := ''
|
||||
for _ in 0 .. len {
|
||||
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||
result += chars[idx].ascii_str()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fn generate_random_bytes() []u8 {
|
||||
len := rand.int_in_range(0, 200) 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_large_payload() []u8 {
|
||||
// Generate payload between 126 and 1000 bytes to test extended length encoding
|
||||
len := rand.int_in_range(126, 1000) or { 200 }
|
||||
mut result := []u8{cap: len}
|
||||
for _ in 0 .. len {
|
||||
result << u8(rand.int_in_range(0, 256) or { 0 })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 5: Frame Encoding
|
||||
// Feature: websocket-helper, Property 5: Frame Encoding
|
||||
// Validates: Requirements 3.1, 3.2, 7.3
|
||||
//
|
||||
// *For any* string data sent via ws.send(data), the resulting WebSocket frame
|
||||
// SHALL have opcode 0x1 (text) and contain the exact string bytes.
|
||||
// ============================================================================
|
||||
fn test_property_5_text_frame_encoding() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate random string data
|
||||
text_data := generate_random_string()
|
||||
|
||||
// Encode as text frame (simulating ws.send(data))
|
||||
frame := encode_ws_frame(ws_opcode_text, text_data.bytes(), false)
|
||||
|
||||
// Decode the frame
|
||||
decoded := decode_ws_frame(frame) or {
|
||||
println(' Iteration ${i}: Failed to decode frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify opcode is text (0x1)
|
||||
if decoded.opcode != ws_opcode_text {
|
||||
println(' Iteration ${i}: Expected text opcode (0x1), got 0x${decoded.opcode:02x}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify payload contains exact string bytes
|
||||
received := decoded.payload.bytestr()
|
||||
if received != text_data {
|
||||
println(' Iteration ${i}: Payload mismatch')
|
||||
println(' Expected: ${text_data}')
|
||||
println(' Got: ${received}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify FIN bit is set (single frame message)
|
||||
if !decoded.fin {
|
||||
println(' Iteration ${i}: FIN bit should be set')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 5: Frame Encoding (Binary)
|
||||
// *For any* binary data sent via ws.send_bytes(data), the resulting frame
|
||||
// SHALL have opcode 0x2 (binary) and contain the exact byte sequence.
|
||||
// ============================================================================
|
||||
fn test_property_5_binary_frame_encoding() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(54321)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate random binary data
|
||||
binary_data := generate_random_bytes()
|
||||
|
||||
// Encode as binary frame (simulating ws.send_bytes(data))
|
||||
frame := encode_ws_frame(ws_opcode_binary, binary_data, false)
|
||||
|
||||
// Decode the frame
|
||||
decoded := decode_ws_frame(frame) or {
|
||||
println(' Iteration ${i}: Failed to decode frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify opcode is binary (0x2)
|
||||
if decoded.opcode != ws_opcode_binary {
|
||||
println(' Iteration ${i}: Expected binary opcode (0x2), got 0x${decoded.opcode:02x}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify payload contains exact byte sequence
|
||||
if decoded.payload != binary_data {
|
||||
println(' Iteration ${i}: Binary payload mismatch')
|
||||
println(' Expected length: ${binary_data.len}')
|
||||
println(' Got length: ${decoded.payload.len}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify FIN bit is set
|
||||
if !decoded.fin {
|
||||
println(' Iteration ${i}: FIN bit should be set')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional test: Extended payload length encoding (126-65535 bytes)
|
||||
// ============================================================================
|
||||
fn test_extended_payload_length() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(99999)])
|
||||
|
||||
for i in 0 .. 20 {
|
||||
// Generate payload that requires extended length encoding
|
||||
payload := generate_large_payload()
|
||||
|
||||
// Encode as text frame
|
||||
frame := encode_ws_frame(ws_opcode_text, payload, false)
|
||||
|
||||
// Decode the frame
|
||||
decoded := decode_ws_frame(frame) or {
|
||||
println(' Iteration ${i}: Failed to decode frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify payload is preserved
|
||||
if decoded.payload != payload {
|
||||
println(' Iteration ${i}: Payload mismatch for extended length')
|
||||
println(' Expected length: ${payload.len}')
|
||||
println(' Got length: ${decoded.payload.len}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify payload length is correctly decoded
|
||||
if decoded.payload_len != u64(payload.len) {
|
||||
println(' Iteration ${i}: Payload length mismatch')
|
||||
println(' Expected: ${payload.len}')
|
||||
println(' Got: ${decoded.payload_len}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional test: Empty payload handling
|
||||
// ============================================================================
|
||||
fn test_empty_payload() bool {
|
||||
// Test empty text frame
|
||||
empty_text := ''
|
||||
text_frame := encode_ws_frame(ws_opcode_text, empty_text.bytes(), false)
|
||||
decoded_text := decode_ws_frame(text_frame) or {
|
||||
println(' Failed to decode empty text frame: ${err}')
|
||||
return false
|
||||
}
|
||||
if decoded_text.payload.len != 0 {
|
||||
println(' Empty text frame should have empty payload')
|
||||
return false
|
||||
}
|
||||
|
||||
// Test empty binary frame
|
||||
empty_binary := []u8{}
|
||||
binary_frame := encode_ws_frame(ws_opcode_binary, empty_binary, false)
|
||||
decoded_binary := decode_ws_frame(binary_frame) or {
|
||||
println(' Failed to decode empty binary frame: ${err}')
|
||||
return false
|
||||
}
|
||||
if decoded_binary.payload.len != 0 {
|
||||
println(' Empty binary frame should have empty payload')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket Frame Encoding Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 5: Frame Encoding
|
||||
// Validates: Requirements 3.1, 3.2, 7.3
|
||||
stats.run_property_test('Property 5: Text Frame Encoding', test_property_5_text_frame_encoding)
|
||||
stats.run_property_test('Property 5: Binary Frame Encoding', test_property_5_binary_frame_encoding)
|
||||
stats.run_property_test('Extended Payload Length', test_extended_payload_length)
|
||||
stats.run_property_test('Empty Payload Handling', test_empty_payload)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
// test_websocket_frame_masking.v - Property test for WebSocket frame masking
|
||||
// Feature: websocket-helper, Property 12: Frame Masking
|
||||
// Validates: Requirements 7.6
|
||||
//
|
||||
// *For any* WebSocket frame received from a client, the frame SHALL be masked
|
||||
// (MASK bit set), and the server SHALL correctly unmask the payload.
|
||||
// *For any* frame sent by the server, the frame SHALL NOT be masked.
|
||||
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)
|
||||
|
||||
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 Frame Masking 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 Encoding/Decoding with custom mask key support
|
||||
// ============================================================================
|
||||
|
||||
struct WSFrame {
|
||||
pub:
|
||||
fin bool
|
||||
opcode u8
|
||||
masked bool
|
||||
mask_key [4]u8
|
||||
payload_len u64
|
||||
payload []u8
|
||||
}
|
||||
|
||||
// encode_ws_frame_with_mask - Encode with a specific mask key for testing
|
||||
fn encode_ws_frame_with_mask(opcode u8, payload []u8, mask_key [4]u8) []u8 {
|
||||
payload_len := payload.len
|
||||
mut frame := []u8{cap: 14 + payload_len}
|
||||
|
||||
// First byte: FIN bit (1) + RSV (000) + opcode (4 bits)
|
||||
frame << u8(0x80 | (opcode & 0x0F))
|
||||
|
||||
// Second byte: MASK bit (1) + payload length
|
||||
if payload_len <= 125 {
|
||||
frame << u8(0x80) | u8(payload_len)
|
||||
} else if payload_len <= 65535 {
|
||||
frame << u8(0x80) | u8(126)
|
||||
frame << u8(payload_len >> 8)
|
||||
frame << u8(payload_len & 0xFF)
|
||||
} else {
|
||||
frame << u8(0x80) | u8(127)
|
||||
for i := 7; i >= 0; i-- {
|
||||
frame << u8((payload_len >> (i * 8)) & 0xFF)
|
||||
}
|
||||
}
|
||||
|
||||
// Add masking key
|
||||
frame << mask_key[0]
|
||||
frame << mask_key[1]
|
||||
frame << mask_key[2]
|
||||
frame << mask_key[3]
|
||||
|
||||
// Add masked payload
|
||||
for i, b in payload {
|
||||
frame << b ^ mask_key[i % 4]
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
// encode_ws_frame - Standard encoding (server frames are unmasked)
|
||||
fn encode_ws_frame(opcode u8, payload []u8, masked bool) []u8 {
|
||||
payload_len := payload.len
|
||||
mut frame := []u8{cap: 14 + payload_len}
|
||||
|
||||
// First byte: FIN bit (1) + RSV (000) + opcode (4 bits)
|
||||
frame << u8(0x80 | (opcode & 0x0F))
|
||||
|
||||
// Second byte: MASK bit + payload length
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Add masking key and masked payload if masked
|
||||
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) !WSFrame {
|
||||
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 WSFrame{
|
||||
fin: fin
|
||||
opcode: opcode
|
||||
masked: masked
|
||||
mask_key: mask_key
|
||||
payload_len: payload_len
|
||||
payload: payload
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
fn generate_random_bytes() []u8 {
|
||||
len := rand.int_in_range(0, 200) 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_random_mask_key() [4]u8 {
|
||||
return [
|
||||
u8(rand.int_in_range(0, 256) or { 0 }),
|
||||
u8(rand.int_in_range(0, 256) or { 0 }),
|
||||
u8(rand.int_in_range(0, 256) or { 0 }),
|
||||
u8(rand.int_in_range(0, 256) or { 0 }),
|
||||
]!
|
||||
}
|
||||
|
||||
fn generate_random_string() string {
|
||||
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
len := rand.int_in_range(1, 100) or { 20 }
|
||||
mut result := ''
|
||||
for _ in 0 .. len {
|
||||
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||
result += chars[idx].ascii_str()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 12: Frame Masking
|
||||
// Feature: websocket-helper, Property 12: Frame Masking
|
||||
// Validates: Requirements 7.6
|
||||
//
|
||||
// *For any* WebSocket frame received from a client, the frame SHALL be masked
|
||||
// (MASK bit set), and the server SHALL correctly unmask the payload.
|
||||
// ============================================================================
|
||||
fn test_property_12_client_frames_masked() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate random payload and mask key (simulating client frame)
|
||||
original_payload := generate_random_bytes()
|
||||
mask_key := generate_random_mask_key()
|
||||
|
||||
// Encode as masked frame (client -> server)
|
||||
frame := encode_ws_frame_with_mask(ws_opcode_text, original_payload, mask_key)
|
||||
|
||||
// Decode the frame (server should unmask)
|
||||
decoded := decode_ws_frame(frame) or {
|
||||
println(' Iteration ${i}: Failed to decode frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify MASK bit was set in the frame
|
||||
if !decoded.masked {
|
||||
println(' Iteration ${i}: Client frame should have MASK bit set')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify payload was correctly unmasked
|
||||
if decoded.payload != original_payload {
|
||||
println(' Iteration ${i}: Payload not correctly unmasked')
|
||||
println(' Expected: ${original_payload}')
|
||||
println(' Got: ${decoded.payload}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 12: Server frames are NOT masked
|
||||
// *For any* frame sent by the server, the frame SHALL NOT be masked.
|
||||
// ============================================================================
|
||||
fn test_property_12_server_frames_unmasked() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(54321)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate random payload
|
||||
original_payload := generate_random_bytes()
|
||||
|
||||
// Encode as unmasked frame (server -> client)
|
||||
frame := encode_ws_frame(ws_opcode_text, original_payload, false)
|
||||
|
||||
// Decode the frame
|
||||
decoded := decode_ws_frame(frame) or {
|
||||
println(' Iteration ${i}: Failed to decode frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify MASK bit is NOT set
|
||||
if decoded.masked {
|
||||
println(' Iteration ${i}: Server frame should NOT have MASK bit set')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify payload is preserved exactly
|
||||
if decoded.payload != original_payload {
|
||||
println(' Iteration ${i}: Payload mismatch')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional test: Masking round-trip (mask then unmask)
|
||||
// ============================================================================
|
||||
fn test_masking_roundtrip() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(99999)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate random data
|
||||
original := generate_random_string()
|
||||
mask_key := generate_random_mask_key()
|
||||
|
||||
// Encode with masking
|
||||
masked_frame := encode_ws_frame_with_mask(ws_opcode_text, original.bytes(), mask_key)
|
||||
|
||||
// Decode (which unmasks)
|
||||
decoded := decode_ws_frame(masked_frame) or {
|
||||
println(' Iteration ${i}: Failed to decode: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify round-trip: unmask(mask(data)) == data
|
||||
if decoded.payload.bytestr() != original {
|
||||
println(' Iteration ${i}: Round-trip failed')
|
||||
println(' Original: ${original}')
|
||||
println(' After round-trip: ${decoded.payload.bytestr()}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional test: Different mask keys produce different masked data
|
||||
// ============================================================================
|
||||
fn test_different_masks_different_output() bool {
|
||||
payload := 'Hello, WebSocket!'.bytes()
|
||||
|
||||
mask1 := [u8(0x12), 0x34, 0x56, 0x78]!
|
||||
mask2 := [u8(0xAB), 0xCD, 0xEF, 0x01]!
|
||||
|
||||
frame1 := encode_ws_frame_with_mask(ws_opcode_text, payload, mask1)
|
||||
frame2 := encode_ws_frame_with_mask(ws_opcode_text, payload, mask2)
|
||||
|
||||
// The frames should be different (different masks)
|
||||
if frame1 == frame2 {
|
||||
println(' Different masks should produce different frames')
|
||||
return false
|
||||
}
|
||||
|
||||
// But both should decode to the same payload
|
||||
decoded1 := decode_ws_frame(frame1) or {
|
||||
println(' Failed to decode frame1')
|
||||
return false
|
||||
}
|
||||
decoded2 := decode_ws_frame(frame2) or {
|
||||
println(' Failed to decode frame2')
|
||||
return false
|
||||
}
|
||||
|
||||
if decoded1.payload != decoded2.payload {
|
||||
println(' Both frames should decode to same payload')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional test: Zero mask key
|
||||
// ============================================================================
|
||||
fn test_zero_mask_key() bool {
|
||||
payload := 'Test with zero mask'.bytes()
|
||||
zero_mask := [u8(0), 0, 0, 0]!
|
||||
|
||||
frame := encode_ws_frame_with_mask(ws_opcode_text, payload, zero_mask)
|
||||
decoded := decode_ws_frame(frame) or {
|
||||
println(' Failed to decode frame with zero mask')
|
||||
return false
|
||||
}
|
||||
|
||||
// With zero mask, XOR should leave payload unchanged
|
||||
if decoded.payload != payload {
|
||||
println(' Zero mask should preserve payload')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket Frame Masking Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 12: Frame Masking
|
||||
// Validates: Requirements 7.6
|
||||
stats.run_property_test('Property 12: Client Frames Masked', test_property_12_client_frames_masked)
|
||||
stats.run_property_test('Property 12: Server Frames Unmasked', test_property_12_server_frames_unmasked)
|
||||
stats.run_property_test('Masking Round-Trip', test_masking_roundtrip)
|
||||
stats.run_property_test('Different Masks Different Output', test_different_masks_different_output)
|
||||
stats.run_property_test('Zero Mask Key', test_zero_mask_key)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// test_websocket_handshake.v - Property test for WebSocket handshake compliance
|
||||
// Feature: websocket-helper, Property 1: WebSocket Handshake Compliance
|
||||
// Validates: Requirements 1.2, 7.1, 7.2
|
||||
//
|
||||
// *For any* valid WebSocket upgrade request containing a Sec-WebSocket-Key header,
|
||||
// the server's Sec-WebSocket-Accept response header SHALL equal
|
||||
// base64(sha1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")).
|
||||
module main
|
||||
|
||||
import rand
|
||||
import time
|
||||
import crypto.sha1
|
||||
import encoding.base64
|
||||
|
||||
const test_iterations = 100
|
||||
|
||||
// WebSocket magic GUID for handshake (RFC 6455)
|
||||
const ws_magic_guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||
|
||||
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 Handshake 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 Handshake Functions (copied from websocket.v for testing)
|
||||
// ============================================================================
|
||||
|
||||
// compute_accept_key - Compute Sec-WebSocket-Accept from Sec-WebSocket-Key
|
||||
// According to RFC 6455: base64(sha1(key + GUID))
|
||||
fn compute_accept_key(key string) string {
|
||||
combined := key + ws_magic_guid
|
||||
hash := sha1.sum(combined.bytes())
|
||||
return base64.encode(hash)
|
||||
}
|
||||
|
||||
// Reference implementation for verification
|
||||
fn reference_compute_accept_key(key string) string {
|
||||
// This is the exact algorithm from RFC 6455
|
||||
combined := key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||
hash := sha1.sum(combined.bytes())
|
||||
return base64.encode(hash)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
// Generate a valid base64-encoded 16-byte key (as per RFC 6455)
|
||||
fn generate_random_websocket_key() string {
|
||||
mut bytes := []u8{cap: 16}
|
||||
for _ in 0 .. 16 {
|
||||
bytes << u8(rand.int_in_range(0, 256) or { 0 })
|
||||
}
|
||||
return base64.encode(bytes)
|
||||
}
|
||||
|
||||
// Generate a random string for testing
|
||||
fn generate_random_string() string {
|
||||
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='
|
||||
len := rand.int_in_range(10, 50) or { 24 }
|
||||
mut result := ''
|
||||
for _ in 0 .. len {
|
||||
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||
result += chars[idx].ascii_str()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 1: WebSocket Handshake Compliance
|
||||
// Feature: websocket-helper, Property 1: WebSocket Handshake Compliance
|
||||
// Validates: Requirements 1.2, 7.1, 7.2
|
||||
//
|
||||
// *For any* valid WebSocket upgrade request containing a Sec-WebSocket-Key header,
|
||||
// the server's Sec-WebSocket-Accept response header SHALL equal
|
||||
// base64(sha1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")).
|
||||
// ============================================================================
|
||||
fn test_property_1_handshake_compliance() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate a random WebSocket key
|
||||
ws_key := generate_random_websocket_key()
|
||||
|
||||
// Compute accept key using our implementation
|
||||
accept_key := compute_accept_key(ws_key)
|
||||
|
||||
// Compute expected accept key using reference implementation
|
||||
expected_accept := reference_compute_accept_key(ws_key)
|
||||
|
||||
// Verify they match
|
||||
if accept_key != expected_accept {
|
||||
println(' Iteration ${i}: Accept key mismatch')
|
||||
println(' Key: ${ws_key}')
|
||||
println(' Expected: ${expected_accept}')
|
||||
println(' Got: ${accept_key}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify the accept key is valid base64
|
||||
decoded := base64.decode(accept_key)
|
||||
if decoded.len != 20 {
|
||||
// SHA-1 produces 20 bytes
|
||||
println(' Iteration ${i}: Accept key should decode to 20 bytes (SHA-1 hash)')
|
||||
println(' Got ${decoded.len} bytes')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test with known RFC 6455 example
|
||||
// ============================================================================
|
||||
fn test_rfc_6455_example() bool {
|
||||
// Example from RFC 6455 Section 1.3
|
||||
// Key: "dGhlIHNhbXBsZSBub25jZQ=="
|
||||
// Expected Accept: "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
||||
|
||||
key := 'dGhlIHNhbXBsZSBub25jZQ=='
|
||||
expected := 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
|
||||
|
||||
accept := compute_accept_key(key)
|
||||
|
||||
if accept != expected {
|
||||
println(' RFC 6455 example failed')
|
||||
println(' Key: ${key}')
|
||||
println(' Expected: ${expected}')
|
||||
println(' Got: ${accept}')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test determinism: same key always produces same accept
|
||||
// ============================================================================
|
||||
fn test_deterministic_accept_key() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(54321)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
ws_key := generate_random_websocket_key()
|
||||
|
||||
// Compute accept key multiple times
|
||||
accept1 := compute_accept_key(ws_key)
|
||||
accept2 := compute_accept_key(ws_key)
|
||||
accept3 := compute_accept_key(ws_key)
|
||||
|
||||
// All should be identical
|
||||
if accept1 != accept2 || accept2 != accept3 {
|
||||
println(' Iteration ${i}: Accept key not deterministic')
|
||||
println(' Key: ${ws_key}')
|
||||
println(' Accept1: ${accept1}')
|
||||
println(' Accept2: ${accept2}')
|
||||
println(' Accept3: ${accept3}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test different keys produce different accepts
|
||||
// ============================================================================
|
||||
fn test_different_keys_different_accepts() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(99999)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
key1 := generate_random_websocket_key()
|
||||
key2 := generate_random_websocket_key()
|
||||
|
||||
// Skip if we randomly generated the same key
|
||||
if key1 == key2 {
|
||||
continue
|
||||
}
|
||||
|
||||
accept1 := compute_accept_key(key1)
|
||||
accept2 := compute_accept_key(key2)
|
||||
|
||||
// Different keys should produce different accepts
|
||||
if accept1 == accept2 {
|
||||
println(' Iteration ${i}: Different keys produced same accept')
|
||||
println(' Key1: ${key1}')
|
||||
println(' Key2: ${key2}')
|
||||
println(' Accept: ${accept1}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test accept key format is valid base64
|
||||
// ============================================================================
|
||||
fn test_accept_key_valid_base64() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(11111)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
ws_key := generate_random_websocket_key()
|
||||
accept_key := compute_accept_key(ws_key)
|
||||
|
||||
// Try to decode - should not fail
|
||||
decoded := base64.decode(accept_key)
|
||||
|
||||
// SHA-1 hash is always 20 bytes
|
||||
if decoded.len != 20 {
|
||||
println(' Iteration ${i}: Invalid accept key length')
|
||||
println(' Expected 20 bytes, got ${decoded.len}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Re-encode should match original
|
||||
re_encoded := base64.encode(decoded)
|
||||
if re_encoded != accept_key {
|
||||
println(' Iteration ${i}: Base64 round-trip failed')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket Handshake Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 1: WebSocket Handshake Compliance
|
||||
// Validates: Requirements 1.2, 7.1, 7.2
|
||||
stats.run_property_test('Property 1: Handshake Compliance', test_property_1_handshake_compliance)
|
||||
stats.run_property_test('RFC 6455 Example', test_rfc_6455_example)
|
||||
stats.run_property_test('Deterministic Accept Key', test_deterministic_accept_key)
|
||||
stats.run_property_test('Different Keys Different Accepts', test_different_keys_different_accepts)
|
||||
stats.run_property_test('Accept Key Valid Base64', test_accept_key_valid_base64)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// test_websocket_handshake_errors.v - Property test for WebSocket handshake error handling
|
||||
// Feature: websocket-helper, Property 2: Handshake Error Handling
|
||||
// Validates: Requirements 1.4
|
||||
//
|
||||
// *For any* HTTP request to a WebSocket route that lacks required WebSocket headers
|
||||
// (Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Key), the server SHALL
|
||||
// return an HTTP 400 Bad Request response.
|
||||
module main
|
||||
|
||||
import rand
|
||||
import time
|
||||
|
||||
const test_iterations = 100
|
||||
|
||||
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 Handshake Error Handling 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')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Context for testing
|
||||
// ============================================================================
|
||||
|
||||
struct MockContext {
|
||||
pub:
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
// validate_upgrade_request - Validate WebSocket upgrade request headers
|
||||
// Returns the WebSocket key if valid, or an error if invalid
|
||||
fn validate_upgrade_request(ctx MockContext) !string {
|
||||
// Check Upgrade header
|
||||
upgrade := ctx.headers['Upgrade'] or {
|
||||
return error('Missing Upgrade header')
|
||||
}
|
||||
if upgrade.to_lower() != 'websocket' {
|
||||
return error('Invalid Upgrade header')
|
||||
}
|
||||
|
||||
// Check Connection header
|
||||
connection := ctx.headers['Connection'] or {
|
||||
return error('Missing Connection header')
|
||||
}
|
||||
if !connection.to_lower().contains('upgrade') {
|
||||
return error('Invalid Connection header')
|
||||
}
|
||||
|
||||
// Check Sec-WebSocket-Key
|
||||
ws_key := ctx.headers['Sec-WebSocket-Key'] or {
|
||||
return error('Missing Sec-WebSocket-Key header')
|
||||
}
|
||||
if ws_key.len == 0 {
|
||||
return error('Empty Sec-WebSocket-Key header')
|
||||
}
|
||||
|
||||
// Check Sec-WebSocket-Version (must be 13)
|
||||
ws_version := ctx.headers['Sec-WebSocket-Version'] or { '13' }
|
||||
if ws_version != '13' {
|
||||
return error('Unsupported WebSocket version')
|
||||
}
|
||||
|
||||
return ws_key
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
fn generate_random_string() string {
|
||||
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
len := rand.int_in_range(5, 30) or { 10 }
|
||||
mut result := ''
|
||||
for _ in 0 .. len {
|
||||
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||
result += chars[idx].ascii_str()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fn generate_valid_websocket_key() string {
|
||||
// Generate a valid base64-encoded 16-byte key
|
||||
chars := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
mut result := ''
|
||||
for _ in 0 .. 22 {
|
||||
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||
result += chars[idx].ascii_str()
|
||||
}
|
||||
return result + '=='
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 2: Handshake Error Handling
|
||||
// Feature: websocket-helper, Property 2: Handshake Error Handling
|
||||
// Validates: Requirements 1.4
|
||||
//
|
||||
// *For any* HTTP request to a WebSocket route that lacks required WebSocket headers,
|
||||
// the server SHALL return an error.
|
||||
// ============================================================================
|
||||
|
||||
// Test: Missing Upgrade header should fail
|
||||
fn test_property_2_missing_upgrade_header() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Create context without Upgrade header
|
||||
ctx := MockContext{
|
||||
headers: {
|
||||
'Connection': 'Upgrade'
|
||||
'Sec-WebSocket-Key': generate_valid_websocket_key()
|
||||
'Sec-WebSocket-Version': '13'
|
||||
}
|
||||
}
|
||||
|
||||
// Validation should fail
|
||||
validate_upgrade_request(ctx) or {
|
||||
// Expected to fail
|
||||
if !err.msg().contains('Upgrade') {
|
||||
println(' Iteration ${i}: Wrong error message: ${err.msg()}')
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If we get here, validation succeeded when it should have failed
|
||||
println(' Iteration ${i}: Should have failed without Upgrade header')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test: Missing Connection header should fail
|
||||
fn test_property_2_missing_connection_header() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(23456)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Create context without Connection header
|
||||
ctx := MockContext{
|
||||
headers: {
|
||||
'Upgrade': 'websocket'
|
||||
'Sec-WebSocket-Key': generate_valid_websocket_key()
|
||||
'Sec-WebSocket-Version': '13'
|
||||
}
|
||||
}
|
||||
|
||||
// Validation should fail
|
||||
validate_upgrade_request(ctx) or {
|
||||
// Expected to fail
|
||||
if !err.msg().contains('Connection') {
|
||||
println(' Iteration ${i}: Wrong error message: ${err.msg()}')
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If we get here, validation succeeded when it should have failed
|
||||
println(' Iteration ${i}: Should have failed without Connection header')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test: Missing Sec-WebSocket-Key header should fail
|
||||
fn test_property_2_missing_websocket_key() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(34567)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Create context without Sec-WebSocket-Key header
|
||||
ctx := MockContext{
|
||||
headers: {
|
||||
'Upgrade': 'websocket'
|
||||
'Connection': 'Upgrade'
|
||||
'Sec-WebSocket-Version': '13'
|
||||
}
|
||||
}
|
||||
|
||||
// Validation should fail
|
||||
validate_upgrade_request(ctx) or {
|
||||
// Expected to fail
|
||||
if !err.msg().contains('Sec-WebSocket-Key') {
|
||||
println(' Iteration ${i}: Wrong error message: ${err.msg()}')
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If we get here, validation succeeded when it should have failed
|
||||
println(' Iteration ${i}: Should have failed without Sec-WebSocket-Key header')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test: Invalid Upgrade header value should fail
|
||||
fn test_property_2_invalid_upgrade_value() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(45678)])
|
||||
|
||||
invalid_values := ['http', 'tcp', 'socket', 'ws', 'WebSocket2', '']
|
||||
|
||||
for i, invalid_value in invalid_values {
|
||||
ctx := MockContext{
|
||||
headers: {
|
||||
'Upgrade': invalid_value
|
||||
'Connection': 'Upgrade'
|
||||
'Sec-WebSocket-Key': generate_valid_websocket_key()
|
||||
'Sec-WebSocket-Version': '13'
|
||||
}
|
||||
}
|
||||
|
||||
// Validation should fail
|
||||
validate_upgrade_request(ctx) or {
|
||||
// Expected to fail
|
||||
continue
|
||||
}
|
||||
|
||||
// If we get here, validation succeeded when it should have failed
|
||||
println(' Test ${i}: Should have failed with invalid Upgrade value: "${invalid_value}"')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test: Invalid Connection header value should fail
|
||||
fn test_property_2_invalid_connection_value() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(56789)])
|
||||
|
||||
invalid_values := ['keep-alive', 'close', 'http', '']
|
||||
|
||||
for i, invalid_value in invalid_values {
|
||||
ctx := MockContext{
|
||||
headers: {
|
||||
'Upgrade': 'websocket'
|
||||
'Connection': invalid_value
|
||||
'Sec-WebSocket-Key': generate_valid_websocket_key()
|
||||
'Sec-WebSocket-Version': '13'
|
||||
}
|
||||
}
|
||||
|
||||
// Validation should fail
|
||||
validate_upgrade_request(ctx) or {
|
||||
// Expected to fail
|
||||
continue
|
||||
}
|
||||
|
||||
// If we get here, validation succeeded when it should have failed
|
||||
println(' Test ${i}: Should have failed with invalid Connection value: "${invalid_value}"')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test: Valid headers should succeed
|
||||
fn test_valid_headers_succeed() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(67890)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
ws_key := generate_valid_websocket_key()
|
||||
|
||||
ctx := MockContext{
|
||||
headers: {
|
||||
'Upgrade': 'websocket'
|
||||
'Connection': 'Upgrade'
|
||||
'Sec-WebSocket-Key': ws_key
|
||||
'Sec-WebSocket-Version': '13'
|
||||
}
|
||||
}
|
||||
|
||||
// Validation should succeed
|
||||
result := validate_upgrade_request(ctx) or {
|
||||
println(' Iteration ${i}: Should have succeeded with valid headers: ${err.msg()}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify the returned key matches
|
||||
if result != ws_key {
|
||||
println(' Iteration ${i}: Returned key mismatch')
|
||||
println(' Expected: ${ws_key}')
|
||||
println(' Got: ${result}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test: Case-insensitive header values
|
||||
fn test_case_insensitive_headers() bool {
|
||||
test_cases := [
|
||||
['WebSocket', 'Upgrade'],
|
||||
['WEBSOCKET', 'UPGRADE'],
|
||||
['websocket', 'upgrade'],
|
||||
['WebSocket', 'upgrade'],
|
||||
['websocket', 'Upgrade, keep-alive'],
|
||||
]
|
||||
|
||||
for i, test_case in test_cases {
|
||||
ws_key := generate_valid_websocket_key()
|
||||
|
||||
ctx := MockContext{
|
||||
headers: {
|
||||
'Upgrade': test_case[0]
|
||||
'Connection': test_case[1]
|
||||
'Sec-WebSocket-Key': ws_key
|
||||
'Sec-WebSocket-Version': '13'
|
||||
}
|
||||
}
|
||||
|
||||
// Validation should succeed
|
||||
validate_upgrade_request(ctx) or {
|
||||
println(' Test ${i}: Should have succeeded with case variation')
|
||||
println(' Upgrade: ${test_case[0]}, Connection: ${test_case[1]}')
|
||||
println(' Error: ${err.msg()}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test: Unsupported WebSocket version should fail
|
||||
fn test_unsupported_websocket_version() bool {
|
||||
invalid_versions := ['8', '9', '10', '11', '12', '14', '0', '']
|
||||
|
||||
for i, version in invalid_versions {
|
||||
ctx := MockContext{
|
||||
headers: {
|
||||
'Upgrade': 'websocket'
|
||||
'Connection': 'Upgrade'
|
||||
'Sec-WebSocket-Key': generate_valid_websocket_key()
|
||||
'Sec-WebSocket-Version': version
|
||||
}
|
||||
}
|
||||
|
||||
// Validation should fail
|
||||
validate_upgrade_request(ctx) or {
|
||||
// Expected to fail
|
||||
continue
|
||||
}
|
||||
|
||||
// If we get here, validation succeeded when it should have failed
|
||||
println(' Test ${i}: Should have failed with unsupported version: "${version}"')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket Handshake Error Handling Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 2: Handshake Error Handling
|
||||
// Validates: Requirements 1.4
|
||||
stats.run_property_test('Property 2: Missing Upgrade Header', test_property_2_missing_upgrade_header)
|
||||
stats.run_property_test('Property 2: Missing Connection Header', test_property_2_missing_connection_header)
|
||||
stats.run_property_test('Property 2: Missing WebSocket Key', test_property_2_missing_websocket_key)
|
||||
stats.run_property_test('Property 2: Invalid Upgrade Value', test_property_2_invalid_upgrade_value)
|
||||
stats.run_property_test('Property 2: Invalid Connection Value', test_property_2_invalid_connection_value)
|
||||
stats.run_property_test('Valid Headers Succeed', test_valid_headers_succeed)
|
||||
stats.run_property_test('Case Insensitive Headers', test_case_insensitive_headers)
|
||||
stats.run_property_test('Unsupported WebSocket Version', test_unsupported_websocket_version)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// test_websocket_json_roundtrip.v - Property test for WebSocket JSON round-trip
|
||||
// Feature: websocket-helper, Property 13: JSON Round-Trip
|
||||
// Validates: Requirements 8.1, 8.2, 8.3
|
||||
//
|
||||
// This test validates that JSON data sent through WebSocket frames
|
||||
// is preserved correctly through the encode/decode cycle.
|
||||
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)
|
||||
|
||||
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 JSON Round-Trip 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 Encoding/Decoding (copied from websocket.v for testing)
|
||||
// ============================================================================
|
||||
|
||||
struct WSFrame {
|
||||
pub:
|
||||
fin bool
|
||||
opcode u8
|
||||
masked bool
|
||||
mask_key [4]u8
|
||||
payload_len u64
|
||||
payload []u8
|
||||
}
|
||||
|
||||
fn encode_ws_frame(opcode u8, payload []u8, masked bool) []u8 {
|
||||
payload_len := payload.len
|
||||
mut frame := []u8{cap: 14 + payload_len}
|
||||
|
||||
// First byte: FIN bit (1) + RSV (000) + opcode (4 bits)
|
||||
frame << u8(0x80 | (opcode & 0x0F))
|
||||
|
||||
// Second byte: MASK bit + payload length
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Add masking key and masked payload if masked
|
||||
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) !WSFrame {
|
||||
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 WSFrame{
|
||||
fin: fin
|
||||
opcode: opcode
|
||||
masked: masked
|
||||
mask_key: mask_key
|
||||
payload_len: payload_len
|
||||
payload: payload
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
fn generate_random_string_value() string {
|
||||
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
len := rand.int_in_range(1, 30) or { 10 }
|
||||
mut result := ''
|
||||
for _ in 0 .. len {
|
||||
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||
result += chars[idx].ascii_str()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fn generate_random_int() int {
|
||||
return rand.int_in_range(-10000, 10000) or { 0 }
|
||||
}
|
||||
|
||||
fn generate_random_bool() bool {
|
||||
return rand.int_in_range(0, 2) or { 0 } == 1
|
||||
}
|
||||
|
||||
fn generate_random_json() string {
|
||||
key := generate_random_string_value()
|
||||
value_type := rand.int_in_range(0, 4) or { 0 }
|
||||
|
||||
match value_type {
|
||||
0 {
|
||||
str_val := generate_random_string_value()
|
||||
return '{"${key}":"${str_val}"}'
|
||||
}
|
||||
1 {
|
||||
int_val := generate_random_int()
|
||||
return '{"${key}":${int_val}}'
|
||||
}
|
||||
2 {
|
||||
bool_val := generate_random_bool()
|
||||
return '{"${key}":${bool_val}}'
|
||||
}
|
||||
3 {
|
||||
return '{"${key}":null}'
|
||||
}
|
||||
else {
|
||||
return '{"test":"value"}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_nested_json() string {
|
||||
key1 := generate_random_string_value()
|
||||
key2 := generate_random_string_value()
|
||||
str_val := generate_random_string_value()
|
||||
int_val := generate_random_int()
|
||||
bool_val := generate_random_bool()
|
||||
|
||||
return '{"${key1}":{"${key2}":"${str_val}","num":${int_val},"flag":${bool_val}}}'
|
||||
}
|
||||
|
||||
fn generate_json_array() string {
|
||||
mut items := []string{}
|
||||
count := rand.int_in_range(1, 5) or { 2 }
|
||||
|
||||
for _ in 0 .. count {
|
||||
item_type := rand.int_in_range(0, 3) or { 0 }
|
||||
match item_type {
|
||||
0 {
|
||||
items << '"${generate_random_string_value()}"'
|
||||
}
|
||||
1 {
|
||||
items << '${generate_random_int()}'
|
||||
}
|
||||
2 {
|
||||
items << '${generate_random_bool()}'
|
||||
}
|
||||
else {
|
||||
items << 'null'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '[${items.join(",")}]'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 13: JSON Round-Trip
|
||||
// Feature: websocket-helper, Property 13: JSON Round-Trip
|
||||
// Validates: Requirements 8.1, 8.2, 8.3
|
||||
//
|
||||
// *For any* valid JSON string, calling ws.send_json(data) SHALL send the data
|
||||
// as a text frame, and parsing the received frame content as JSON SHALL
|
||||
// produce an equivalent value.
|
||||
// ============================================================================
|
||||
fn test_property_13_json_roundtrip() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate random JSON data
|
||||
json_type := rand.int_in_range(0, 3) or { 0 }
|
||||
json_data := match json_type {
|
||||
0 { generate_random_json() }
|
||||
1 { generate_nested_json() }
|
||||
2 { generate_json_array() }
|
||||
else { '{"test":"value"}' }
|
||||
}
|
||||
|
||||
// Encode the JSON as a WebSocket text frame (simulating send_json)
|
||||
frame := encode_ws_frame(ws_opcode_text, json_data.bytes(), false)
|
||||
|
||||
// Decode the frame
|
||||
decoded := decode_ws_frame(frame) or {
|
||||
println(' Iteration ${i}: Failed to decode frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify it's a text frame
|
||||
if decoded.opcode != ws_opcode_text {
|
||||
println(' Iteration ${i}: Expected text opcode (0x1), got 0x${decoded.opcode:02x}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract the payload as string
|
||||
received_json := decoded.payload.bytestr()
|
||||
|
||||
// Verify the JSON content is preserved exactly
|
||||
if received_json != json_data {
|
||||
println(' Iteration ${i}: JSON mismatch')
|
||||
println(' Expected: ${json_data}')
|
||||
println(' Got: ${received_json}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional test: JSON with special characters
|
||||
// ============================================================================
|
||||
fn test_json_special_chars() bool {
|
||||
test_cases := [
|
||||
'{"msg":"hello\\nworld"}',
|
||||
'{"msg":"tab\\there"}',
|
||||
'{"msg":"quote\\"test\\""}',
|
||||
'{"empty":""}',
|
||||
'{"unicode":"test"}',
|
||||
'[]',
|
||||
'{}',
|
||||
'{"a":1,"b":2,"c":3}',
|
||||
]
|
||||
|
||||
for i, json_data in test_cases {
|
||||
frame := encode_ws_frame(ws_opcode_text, json_data.bytes(), false)
|
||||
|
||||
decoded := decode_ws_frame(frame) or {
|
||||
println(' Test case ${i}: Failed to decode frame: ${err}')
|
||||
return false
|
||||
}
|
||||
|
||||
received := decoded.payload.bytestr()
|
||||
if received != json_data {
|
||||
println(' Test case ${i}: Mismatch')
|
||||
println(' Expected: ${json_data}')
|
||||
println(' Got: ${received}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional test: Verify JSON uses text opcode
|
||||
// ============================================================================
|
||||
fn test_json_uses_text_opcode() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(54321)])
|
||||
|
||||
for _ in 0 .. test_iterations {
|
||||
json_data := generate_random_json()
|
||||
|
||||
// Encode as text frame (what send_json should do)
|
||||
text_frame := encode_ws_frame(ws_opcode_text, json_data.bytes(), false)
|
||||
|
||||
// Decode and verify opcode
|
||||
decoded := decode_ws_frame(text_frame) or {
|
||||
println(' Failed to decode frame')
|
||||
return false
|
||||
}
|
||||
|
||||
if decoded.opcode != ws_opcode_text {
|
||||
println(' JSON should be sent as text frame (opcode 0x1), got 0x${decoded.opcode:02x}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket JSON Round-Trip Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 13: JSON Round-Trip
|
||||
// Validates: Requirements 8.1, 8.2, 8.3
|
||||
stats.run_property_test('Property 13: JSON Round-Trip', test_property_13_json_roundtrip)
|
||||
stats.run_property_test('JSON Special Characters', test_json_special_chars)
|
||||
stats.run_property_test('JSON Uses Text Opcode', test_json_uses_text_opcode)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
// test_websocket_lifecycle_integration.v - Integration test for WebSocket full connection lifecycle
|
||||
// Feature: websocket-helper, Property 4: Event Callback Invocation
|
||||
// Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5
|
||||
//
|
||||
// *For any* WebSocket connection lifecycle, the event callbacks SHALL be invoked in the
|
||||
// correct order: `on_open` (once on connection), `on_message` (for each message), and
|
||||
// `on_close` (once on disconnection), with each callback receiving a valid WSContext.
|
||||
module main
|
||||
|
||||
import rand
|
||||
import time
|
||||
import net.http
|
||||
|
||||
const test_iterations = 100
|
||||
|
||||
// WebSocket magic GUID for handshake (RFC 6455)
|
||||
const ws_magic_guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
||||
|
||||
// WebSocket opcodes
|
||||
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_normal = 1000
|
||||
|
||||
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 Lifecycle Integration 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')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Minimal type definitions for testing
|
||||
// ============================================================================
|
||||
|
||||
enum WSReadyState {
|
||||
connecting = 0
|
||||
open = 1
|
||||
closing = 2
|
||||
closed = 3
|
||||
}
|
||||
|
||||
struct WSMessageEvent {
|
||||
pub:
|
||||
data string
|
||||
data_bytes []u8
|
||||
is_binary bool
|
||||
}
|
||||
|
||||
struct WSCloseEvent {
|
||||
pub:
|
||||
code int
|
||||
reason string
|
||||
was_clean bool
|
||||
}
|
||||
|
||||
@[heap]
|
||||
struct Context {
|
||||
pub:
|
||||
req http.Request
|
||||
params map[string]string
|
||||
query map[string]string
|
||||
url string
|
||||
path string
|
||||
pub mut:
|
||||
status_code int = 200
|
||||
headers map[string]string
|
||||
body string
|
||||
store map[string]string
|
||||
}
|
||||
|
||||
struct WSContext {
|
||||
pub:
|
||||
http_ctx &Context
|
||||
params map[string]string
|
||||
query map[string]string
|
||||
store map[string]string
|
||||
pub mut:
|
||||
ready_state WSReadyState
|
||||
protocol string
|
||||
socket voidptr
|
||||
send_fn fn ([]u8) ! = unsafe { nil }
|
||||
close_fn fn (int, string) ! = unsafe { nil }
|
||||
}
|
||||
|
||||
fn (ws WSContext) is_open() bool {
|
||||
return ws.ready_state == .open
|
||||
}
|
||||
|
||||
fn (ws WSContext) can_send() bool {
|
||||
return ws.ready_state == .open
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event Tracking for Testing
|
||||
// ============================================================================
|
||||
|
||||
enum EventType {
|
||||
on_open
|
||||
on_message
|
||||
on_close
|
||||
on_error
|
||||
}
|
||||
|
||||
struct EventRecord {
|
||||
event_type EventType
|
||||
timestamp i64
|
||||
message_data string
|
||||
is_binary bool
|
||||
close_code int
|
||||
close_reason string
|
||||
error_message string
|
||||
ws_valid bool // Whether WSContext was valid when callback was invoked
|
||||
}
|
||||
|
||||
// EventTracker - Tracks all events during a WebSocket lifecycle
|
||||
struct EventTracker {
|
||||
mut:
|
||||
events []EventRecord
|
||||
}
|
||||
|
||||
fn (mut tracker EventTracker) record_open(ws WSContext) {
|
||||
tracker.events << EventRecord{
|
||||
event_type: .on_open
|
||||
timestamp: time.now().unix_milli()
|
||||
ws_valid: ws.http_ctx != unsafe { nil }
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut tracker EventTracker) record_message(event WSMessageEvent, ws WSContext) {
|
||||
tracker.events << EventRecord{
|
||||
event_type: .on_message
|
||||
timestamp: time.now().unix_milli()
|
||||
message_data: event.data
|
||||
is_binary: event.is_binary
|
||||
ws_valid: ws.http_ctx != unsafe { nil }
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut tracker EventTracker) record_close(event WSCloseEvent, ws WSContext) {
|
||||
tracker.events << EventRecord{
|
||||
event_type: .on_close
|
||||
timestamp: time.now().unix_milli()
|
||||
close_code: event.code
|
||||
close_reason: event.reason
|
||||
ws_valid: ws.http_ctx != unsafe { nil }
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut tracker EventTracker) record_error(error_msg string, ws WSContext) {
|
||||
tracker.events << EventRecord{
|
||||
event_type: .on_error
|
||||
timestamp: time.now().unix_milli()
|
||||
error_message: error_msg
|
||||
ws_valid: ws.http_ctx != unsafe { nil }
|
||||
}
|
||||
}
|
||||
|
||||
fn (tracker EventTracker) get_event_sequence() []EventType {
|
||||
mut sequence := []EventType{}
|
||||
for event in tracker.events {
|
||||
sequence << event.event_type
|
||||
}
|
||||
return sequence
|
||||
}
|
||||
|
||||
fn (tracker EventTracker) count_events(event_type EventType) int {
|
||||
mut count := 0
|
||||
for event in tracker.events {
|
||||
if event.event_type == event_type {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
fn (tracker EventTracker) all_ws_contexts_valid() bool {
|
||||
for event in tracker.events {
|
||||
if !event.ws_valid {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Frame Encoding/Decoding (for simulation)
|
||||
// ============================================================================
|
||||
|
||||
struct WSFrame {
|
||||
pub:
|
||||
fin bool
|
||||
opcode u8
|
||||
masked bool
|
||||
mask_key [4]u8
|
||||
payload_len u64
|
||||
payload []u8
|
||||
}
|
||||
|
||||
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) !WSFrame {
|
||||
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 WSFrame{
|
||||
fin: fin
|
||||
opcode: opcode
|
||||
masked: masked
|
||||
mask_key: mask_key
|
||||
payload_len: payload_len
|
||||
payload: payload
|
||||
}
|
||||
}
|
||||
|
||||
fn build_close_frame(code int, reason string) []u8 {
|
||||
mut payload := []u8{cap: 2 + reason.len}
|
||||
payload << u8(code >> 8)
|
||||
payload << u8(code & 0xFF)
|
||||
if reason.len > 0 {
|
||||
payload << reason.bytes()
|
||||
}
|
||||
return encode_ws_frame(ws_opcode_close, payload, false)
|
||||
}
|
||||
|
||||
fn parse_close_frame(payload []u8) (int, string) {
|
||||
mut code := ws_close_normal
|
||||
mut reason := ''
|
||||
|
||||
if payload.len >= 2 {
|
||||
code = int(payload[0]) << 8 | int(payload[1])
|
||||
if payload.len > 2 {
|
||||
reason = payload[2..].bytestr()
|
||||
}
|
||||
}
|
||||
|
||||
return code, reason
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection Lifecycle Simulator
|
||||
// ============================================================================
|
||||
|
||||
// Simulates a complete WebSocket connection lifecycle
|
||||
struct LifecycleSimulator {
|
||||
mut:
|
||||
tracker EventTracker
|
||||
ws_ctx WSContext
|
||||
http_ctx Context
|
||||
is_open bool
|
||||
messages []string
|
||||
close_code int
|
||||
close_reason string
|
||||
}
|
||||
|
||||
fn create_lifecycle_simulator() LifecycleSimulator {
|
||||
http_ctx := Context{
|
||||
req: http.Request{
|
||||
method: .get
|
||||
url: '/ws/test'
|
||||
}
|
||||
params: {'room': 'test-room', 'id': '123'}
|
||||
query: {'token': 'abc123'}
|
||||
url: '/ws/test?token=abc123'
|
||||
path: '/ws/test'
|
||||
store: {'user_id': 'user-456'}
|
||||
}
|
||||
|
||||
ws_ctx := WSContext{
|
||||
http_ctx: &http_ctx
|
||||
params: http_ctx.params.clone()
|
||||
query: http_ctx.query.clone()
|
||||
store: http_ctx.store.clone()
|
||||
ready_state: .connecting
|
||||
protocol: ''
|
||||
socket: unsafe { nil }
|
||||
}
|
||||
|
||||
return LifecycleSimulator{
|
||||
tracker: EventTracker{}
|
||||
ws_ctx: ws_ctx
|
||||
http_ctx: http_ctx
|
||||
is_open: false
|
||||
messages: []string{}
|
||||
close_code: 0
|
||||
close_reason: ''
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut sim LifecycleSimulator) simulate_open() {
|
||||
sim.ws_ctx.ready_state = .open
|
||||
sim.is_open = true
|
||||
sim.tracker.record_open(sim.ws_ctx)
|
||||
}
|
||||
|
||||
fn (mut sim LifecycleSimulator) simulate_message(data string, is_binary bool) {
|
||||
if !sim.is_open {
|
||||
return
|
||||
}
|
||||
|
||||
event := WSMessageEvent{
|
||||
data: data
|
||||
data_bytes: data.bytes()
|
||||
is_binary: is_binary
|
||||
}
|
||||
sim.messages << data
|
||||
sim.tracker.record_message(event, sim.ws_ctx)
|
||||
}
|
||||
|
||||
fn (mut sim LifecycleSimulator) simulate_close(code int, reason string) {
|
||||
if !sim.is_open {
|
||||
return
|
||||
}
|
||||
|
||||
sim.ws_ctx.ready_state = .closing
|
||||
sim.close_code = code
|
||||
sim.close_reason = reason
|
||||
|
||||
event := WSCloseEvent{
|
||||
code: code
|
||||
reason: reason
|
||||
was_clean: code == ws_close_normal
|
||||
}
|
||||
sim.tracker.record_close(event, sim.ws_ctx)
|
||||
|
||||
sim.ws_ctx.ready_state = .closed
|
||||
sim.is_open = false
|
||||
}
|
||||
|
||||
fn (mut sim LifecycleSimulator) simulate_error(error_msg string) {
|
||||
sim.tracker.record_error(error_msg, sim.ws_ctx)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
fn generate_random_string(min_len int, max_len int) string {
|
||||
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
|
||||
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
|
||||
mut result := ''
|
||||
for _ in 0 .. len {
|
||||
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||
result += chars[idx].ascii_str()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fn generate_random_message_count() int {
|
||||
return rand.int_in_range(0, 11) or { 0 } // 0 to 10 messages
|
||||
}
|
||||
|
||||
fn generate_random_close_code() int {
|
||||
codes := [1000, 1001, 1002, 1003, 1007, 1008, 1009, 1011]
|
||||
idx := rand.int_in_range(0, codes.len) or { 0 }
|
||||
return codes[idx]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 4: Event Callback Invocation
|
||||
// Feature: websocket-helper, Property 4: Event Callback Invocation
|
||||
// Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5
|
||||
// ============================================================================
|
||||
|
||||
// Test that on_open is called exactly once at connection start
|
||||
fn test_property_4_on_open_called_once() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
mut sim := create_lifecycle_simulator()
|
||||
|
||||
// Simulate connection open
|
||||
sim.simulate_open()
|
||||
|
||||
// Send random number of messages
|
||||
msg_count := generate_random_message_count()
|
||||
for _ in 0 .. msg_count {
|
||||
sim.simulate_message(generate_random_string(5, 50), false)
|
||||
}
|
||||
|
||||
// Close connection
|
||||
sim.simulate_close(generate_random_close_code(), 'test close')
|
||||
|
||||
// Verify on_open was called exactly once
|
||||
open_count := sim.tracker.count_events(.on_open)
|
||||
if open_count != 1 {
|
||||
println(' Iteration ${i}: on_open should be called exactly once')
|
||||
println(' Expected: 1, Got: ${open_count}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test that on_message is called for each message
|
||||
fn test_property_4_on_message_called_for_each() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(23456)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
mut sim := create_lifecycle_simulator()
|
||||
|
||||
sim.simulate_open()
|
||||
|
||||
// Send random number of messages
|
||||
msg_count := generate_random_message_count()
|
||||
for _ in 0 .. msg_count {
|
||||
sim.simulate_message(generate_random_string(5, 50), false)
|
||||
}
|
||||
|
||||
sim.simulate_close(ws_close_normal, '')
|
||||
|
||||
// Verify on_message was called for each message
|
||||
message_count := sim.tracker.count_events(.on_message)
|
||||
if message_count != msg_count {
|
||||
println(' Iteration ${i}: on_message count mismatch')
|
||||
println(' Expected: ${msg_count}, Got: ${message_count}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test that on_close is called exactly once at disconnection
|
||||
fn test_property_4_on_close_called_once() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(34567)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
mut sim := create_lifecycle_simulator()
|
||||
|
||||
sim.simulate_open()
|
||||
|
||||
msg_count := generate_random_message_count()
|
||||
for _ in 0 .. msg_count {
|
||||
sim.simulate_message(generate_random_string(5, 50), false)
|
||||
}
|
||||
|
||||
sim.simulate_close(generate_random_close_code(), 'test close')
|
||||
|
||||
// Verify on_close was called exactly once
|
||||
close_count := sim.tracker.count_events(.on_close)
|
||||
if close_count != 1 {
|
||||
println(' Iteration ${i}: on_close should be called exactly once')
|
||||
println(' Expected: 1, Got: ${close_count}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test that events are called in correct order: open -> messages -> close
|
||||
fn test_property_4_event_order() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(45678)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
mut sim := create_lifecycle_simulator()
|
||||
|
||||
sim.simulate_open()
|
||||
|
||||
msg_count := generate_random_message_count()
|
||||
for _ in 0 .. msg_count {
|
||||
sim.simulate_message(generate_random_string(5, 50), false)
|
||||
}
|
||||
|
||||
sim.simulate_close(ws_close_normal, '')
|
||||
|
||||
// Get event sequence
|
||||
sequence := sim.tracker.get_event_sequence()
|
||||
|
||||
// Verify sequence starts with on_open
|
||||
if sequence.len == 0 || sequence[0] != .on_open {
|
||||
println(' Iteration ${i}: First event should be on_open')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify sequence ends with on_close
|
||||
if sequence[sequence.len - 1] != .on_close {
|
||||
println(' Iteration ${i}: Last event should be on_close')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify all messages are between open and close
|
||||
mut found_close := false
|
||||
for j := 1; j < sequence.len - 1; j++ {
|
||||
if sequence[j] == .on_close {
|
||||
found_close = true
|
||||
}
|
||||
if found_close && sequence[j] == .on_message {
|
||||
println(' Iteration ${i}: on_message should not occur after on_close')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test that all callbacks receive valid WSContext
|
||||
fn test_property_4_valid_ws_context() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(56789)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
mut sim := create_lifecycle_simulator()
|
||||
|
||||
sim.simulate_open()
|
||||
|
||||
msg_count := generate_random_message_count()
|
||||
for _ in 0 .. msg_count {
|
||||
sim.simulate_message(generate_random_string(5, 50), false)
|
||||
}
|
||||
|
||||
sim.simulate_close(ws_close_normal, '')
|
||||
|
||||
// Verify all callbacks received valid WSContext
|
||||
if !sim.tracker.all_ws_contexts_valid() {
|
||||
println(' Iteration ${i}: Some callbacks received invalid WSContext')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test message data is correctly passed to on_message callback
|
||||
fn test_property_4_message_data_preserved() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(67890)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
mut sim := create_lifecycle_simulator()
|
||||
|
||||
sim.simulate_open()
|
||||
|
||||
// Generate and send messages
|
||||
mut expected_messages := []string{}
|
||||
msg_count := rand.int_in_range(1, 6) or { 1 }
|
||||
for _ in 0 .. msg_count {
|
||||
msg := generate_random_string(5, 50)
|
||||
expected_messages << msg
|
||||
sim.simulate_message(msg, false)
|
||||
}
|
||||
|
||||
sim.simulate_close(ws_close_normal, '')
|
||||
|
||||
// Verify message data was preserved
|
||||
mut msg_idx := 0
|
||||
for event in sim.tracker.events {
|
||||
if event.event_type == .on_message {
|
||||
if msg_idx >= expected_messages.len {
|
||||
println(' Iteration ${i}: More messages recorded than sent')
|
||||
return false
|
||||
}
|
||||
if event.message_data != expected_messages[msg_idx] {
|
||||
println(' Iteration ${i}: Message data mismatch at index ${msg_idx}')
|
||||
println(' Expected: ${expected_messages[msg_idx]}')
|
||||
println(' Got: ${event.message_data}')
|
||||
return false
|
||||
}
|
||||
msg_idx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test close code and reason are correctly passed to on_close callback
|
||||
fn test_property_4_close_data_preserved() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(78901)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
mut sim := create_lifecycle_simulator()
|
||||
|
||||
sim.simulate_open()
|
||||
|
||||
// Generate random close code and reason
|
||||
close_code := generate_random_close_code()
|
||||
close_reason := generate_random_string(5, 30)
|
||||
|
||||
sim.simulate_close(close_code, close_reason)
|
||||
|
||||
// Find close event and verify data
|
||||
mut found_close := false
|
||||
for event in sim.tracker.events {
|
||||
if event.event_type == .on_close {
|
||||
found_close = true
|
||||
if event.close_code != close_code {
|
||||
println(' Iteration ${i}: Close code mismatch')
|
||||
println(' Expected: ${close_code}, Got: ${event.close_code}')
|
||||
return false
|
||||
}
|
||||
if event.close_reason != close_reason {
|
||||
println(' Iteration ${i}: Close reason mismatch')
|
||||
println(' Expected: ${close_reason}, Got: ${event.close_reason}')
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found_close {
|
||||
println(' Iteration ${i}: No close event found')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test binary message handling
|
||||
fn test_property_4_binary_message_handling() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(89012)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
mut sim := create_lifecycle_simulator()
|
||||
|
||||
sim.simulate_open()
|
||||
|
||||
// Send mix of text and binary messages
|
||||
mut expected_binary_flags := []bool{}
|
||||
msg_count := rand.int_in_range(1, 6) or { 1 }
|
||||
for _ in 0 .. msg_count {
|
||||
is_binary := rand.int_in_range(0, 2) or { 0 } == 1
|
||||
expected_binary_flags << is_binary
|
||||
sim.simulate_message(generate_random_string(5, 50), is_binary)
|
||||
}
|
||||
|
||||
sim.simulate_close(ws_close_normal, '')
|
||||
|
||||
// Verify binary flags were preserved
|
||||
mut msg_idx := 0
|
||||
for event in sim.tracker.events {
|
||||
if event.event_type == .on_message {
|
||||
if msg_idx >= expected_binary_flags.len {
|
||||
println(' Iteration ${i}: More messages recorded than sent')
|
||||
return false
|
||||
}
|
||||
if event.is_binary != expected_binary_flags[msg_idx] {
|
||||
println(' Iteration ${i}: Binary flag mismatch at index ${msg_idx}')
|
||||
println(' Expected: ${expected_binary_flags[msg_idx]}, Got: ${event.is_binary}')
|
||||
return false
|
||||
}
|
||||
msg_idx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test complete lifecycle with all event types
|
||||
fn test_property_4_complete_lifecycle() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(90123)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
mut sim := create_lifecycle_simulator()
|
||||
|
||||
// Full lifecycle: open -> messages -> close
|
||||
sim.simulate_open()
|
||||
|
||||
msg_count := generate_random_message_count()
|
||||
for _ in 0 .. msg_count {
|
||||
sim.simulate_message(generate_random_string(5, 50), false)
|
||||
}
|
||||
|
||||
sim.simulate_close(ws_close_normal, 'Normal closure')
|
||||
|
||||
// Verify complete lifecycle
|
||||
sequence := sim.tracker.get_event_sequence()
|
||||
|
||||
// Should have: 1 open + N messages + 1 close
|
||||
expected_len := 1 + msg_count + 1
|
||||
if sequence.len != expected_len {
|
||||
println(' Iteration ${i}: Event count mismatch')
|
||||
println(' Expected: ${expected_len}, Got: ${sequence.len}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify structure
|
||||
if sequence[0] != .on_open {
|
||||
println(' Iteration ${i}: First event should be on_open')
|
||||
return false
|
||||
}
|
||||
|
||||
for j := 1; j < sequence.len - 1; j++ {
|
||||
if sequence[j] != .on_message {
|
||||
println(' Iteration ${i}: Middle events should be on_message')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if sequence[sequence.len - 1] != .on_close {
|
||||
println(' Iteration ${i}: Last event should be on_close')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test error callback invocation
|
||||
fn test_property_4_error_callback() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(11111)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
mut sim := create_lifecycle_simulator()
|
||||
|
||||
sim.simulate_open()
|
||||
|
||||
// Simulate an error
|
||||
error_msg := 'Test error: ${generate_random_string(10, 30)}'
|
||||
sim.simulate_error(error_msg)
|
||||
|
||||
sim.simulate_close(1011, 'Internal error')
|
||||
|
||||
// Verify error was recorded
|
||||
error_count := sim.tracker.count_events(.on_error)
|
||||
if error_count != 1 {
|
||||
println(' Iteration ${i}: on_error should be called once')
|
||||
println(' Expected: 1, Got: ${error_count}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify error message was preserved
|
||||
for event in sim.tracker.events {
|
||||
if event.event_type == .on_error {
|
||||
if event.error_message != error_msg {
|
||||
println(' Iteration ${i}: Error message mismatch')
|
||||
println(' Expected: ${error_msg}')
|
||||
println(' Got: ${event.error_message}')
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket Lifecycle Integration Tests...')
|
||||
println('Feature: websocket-helper, Property 4: Event Callback Invocation')
|
||||
println('Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
stats.run_property_test('Property 4.1: on_open Called Once', test_property_4_on_open_called_once)
|
||||
stats.run_property_test('Property 4.2: on_message Called For Each', test_property_4_on_message_called_for_each)
|
||||
stats.run_property_test('Property 4.3: on_close Called Once', test_property_4_on_close_called_once)
|
||||
stats.run_property_test('Property 4.4: Event Order (open -> messages -> close)', test_property_4_event_order)
|
||||
stats.run_property_test('Property 4.5: Valid WSContext in All Callbacks', test_property_4_valid_ws_context)
|
||||
stats.run_property_test('Property 4.6: Message Data Preserved', test_property_4_message_data_preserved)
|
||||
stats.run_property_test('Property 4.7: Close Data Preserved', test_property_4_close_data_preserved)
|
||||
stats.run_property_test('Property 4.8: Binary Message Handling', test_property_4_binary_message_handling)
|
||||
stats.run_property_test('Property 4.9: Complete Lifecycle', test_property_4_complete_lifecycle)
|
||||
stats.run_property_test('Property 4.10: Error Callback', test_property_4_error_callback)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// test_websocket_route_patterns.v - Property test for WebSocket route pattern support
|
||||
// Feature: websocket-helper, Property 3: Route Pattern Support
|
||||
// Validates: Requirements 1.5
|
||||
//
|
||||
// *For any* WebSocket route registered with path parameters (e.g., /ws/:room/:id),
|
||||
// when a matching request arrives, the handler SHALL receive a WSContext with
|
||||
// correctly extracted parameter values.
|
||||
module main
|
||||
|
||||
import rand
|
||||
import time
|
||||
import regex
|
||||
|
||||
const test_iterations = 100
|
||||
|
||||
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 Route Pattern 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')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Route Parameter Extraction (simplified from router.v)
|
||||
// ============================================================================
|
||||
|
||||
// Extract parameter names from a route pattern
|
||||
fn extract_param_names(pattern string) []string {
|
||||
mut param_names := []string{}
|
||||
mut param_reg := regex.regex_opt(r':[a-zA-Z_][a-zA-Z0-9_]*') or { return param_names }
|
||||
all_params := param_reg.find_all_str(pattern)
|
||||
for param in all_params {
|
||||
param_names << param[1..] // Remove the colon
|
||||
}
|
||||
return param_names
|
||||
}
|
||||
|
||||
// Convert route pattern to regex and extract params from actual path
|
||||
fn match_route_pattern(pattern string, actual_path string) ?map[string]string {
|
||||
if !pattern.contains(':') {
|
||||
// Static route - no params
|
||||
if pattern == actual_path {
|
||||
return map[string]string{}
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
// Extract parameter names
|
||||
param_names := extract_param_names(pattern)
|
||||
|
||||
// Build regex pattern
|
||||
mut regex_pattern := pattern
|
||||
|
||||
// Escape special regex characters
|
||||
special_chars := ['?', '+', '.', '(', ')', '[', ']', '{', '}', '^', '\$', '|']
|
||||
for ch in special_chars {
|
||||
regex_pattern = regex_pattern.replace(ch, '\\${ch}')
|
||||
}
|
||||
|
||||
// Replace :param with capture groups
|
||||
mut param_reg := regex.regex_opt(r':[a-zA-Z_][a-zA-Z0-9_]*') or { return none }
|
||||
regex_pattern = param_reg.replace_by_fn(regex_pattern, fn (re regex.RE, in_txt string, start int, end int) string {
|
||||
param_name := in_txt[start + 1..end]
|
||||
return '(?P<${param_name}>[^/]+)'
|
||||
})
|
||||
|
||||
// Add anchors
|
||||
regex_pattern = '^${regex_pattern}\$'
|
||||
|
||||
// Compile and match
|
||||
mut compiled := regex.regex_opt(regex_pattern) or { return none }
|
||||
if !compiled.matches_string(actual_path) {
|
||||
return none
|
||||
}
|
||||
|
||||
// Extract parameter values
|
||||
mut params := map[string]string{}
|
||||
for param_name in param_names {
|
||||
value := compiled.get_group_by_name(actual_path, param_name)
|
||||
params[param_name] = value
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
fn generate_random_param_name() string {
|
||||
chars := 'abcdefghijklmnopqrstuvwxyz'
|
||||
len := rand.int_in_range(3, 10) or { 5 }
|
||||
mut result := ''
|
||||
for _ in 0 .. len {
|
||||
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||
result += chars[idx].ascii_str()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fn generate_random_param_value() string {
|
||||
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'
|
||||
len := rand.int_in_range(1, 20) or { 5 }
|
||||
mut result := ''
|
||||
for _ in 0 .. len {
|
||||
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||
result += chars[idx].ascii_str()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fn generate_random_static_segment() string {
|
||||
chars := 'abcdefghijklmnopqrstuvwxyz'
|
||||
len := rand.int_in_range(2, 10) or { 5 }
|
||||
mut result := ''
|
||||
for _ in 0 .. len {
|
||||
idx := rand.int_in_range(0, chars.len) or { 0 }
|
||||
result += chars[idx].ascii_str()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Generate a random route pattern with parameters
|
||||
fn generate_random_route_pattern() (string, []string) {
|
||||
num_segments := rand.int_in_range(2, 6) or { 3 }
|
||||
mut segments := []string{}
|
||||
mut param_names := []string{}
|
||||
|
||||
// First segment is always static (like /ws or /api)
|
||||
segments << generate_random_static_segment()
|
||||
|
||||
for _ in 1 .. num_segments {
|
||||
// Randomly decide if this segment is a parameter or static
|
||||
is_param := rand.int_in_range(0, 2) or { 0 } == 1
|
||||
|
||||
if is_param {
|
||||
param_name := generate_random_param_name()
|
||||
// Ensure unique param names
|
||||
if param_name !in param_names {
|
||||
param_names << param_name
|
||||
segments << ':${param_name}'
|
||||
} else {
|
||||
segments << generate_random_static_segment()
|
||||
}
|
||||
} else {
|
||||
segments << generate_random_static_segment()
|
||||
}
|
||||
}
|
||||
|
||||
pattern := '/' + segments.join('/')
|
||||
return pattern, param_names
|
||||
}
|
||||
|
||||
// Generate an actual path that matches a pattern with specific values
|
||||
fn generate_matching_path(pattern string, param_values map[string]string) string {
|
||||
mut path := pattern
|
||||
for name, value in param_values {
|
||||
path = path.replace(':${name}', value)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Property 3: Route Pattern Support
|
||||
// Feature: websocket-helper, Property 3: Route Pattern Support
|
||||
// Validates: Requirements 1.5
|
||||
//
|
||||
// *For any* WebSocket route registered with path parameters (e.g., /ws/:room/:id),
|
||||
// when a matching request arrives, the handler SHALL receive a WSContext with
|
||||
// correctly extracted parameter values.
|
||||
// ============================================================================
|
||||
|
||||
// Test single parameter extraction
|
||||
fn test_property_3_single_param() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate a pattern with one parameter
|
||||
param_name := generate_random_param_name()
|
||||
prefix := generate_random_static_segment()
|
||||
pattern := '/${prefix}/:${param_name}'
|
||||
|
||||
// Generate a random value for the parameter
|
||||
param_value := generate_random_param_value()
|
||||
actual_path := '/${prefix}/${param_value}'
|
||||
|
||||
// Extract parameters
|
||||
params := match_route_pattern(pattern, actual_path) or {
|
||||
println(' Iteration ${i}: Pattern did not match')
|
||||
println(' Pattern: ${pattern}')
|
||||
println(' Path: ${actual_path}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify parameter was extracted correctly
|
||||
if param_name !in params {
|
||||
println(' Iteration ${i}: Parameter not found: ${param_name}')
|
||||
return false
|
||||
}
|
||||
|
||||
if params[param_name] != param_value {
|
||||
println(' Iteration ${i}: Parameter value mismatch')
|
||||
println(' Expected: ${param_value}')
|
||||
println(' Got: ${params[param_name]}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test multiple parameter extraction
|
||||
fn test_property_3_multiple_params() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(23456)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate a pattern with multiple parameters
|
||||
pattern, param_names := generate_random_route_pattern()
|
||||
|
||||
// Skip if no parameters were generated
|
||||
if param_names.len == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate random values for each parameter
|
||||
mut expected_values := map[string]string{}
|
||||
for name in param_names {
|
||||
expected_values[name] = generate_random_param_value()
|
||||
}
|
||||
|
||||
// Generate the actual path
|
||||
actual_path := generate_matching_path(pattern, expected_values)
|
||||
|
||||
// Extract parameters
|
||||
params := match_route_pattern(pattern, actual_path) or {
|
||||
println(' Iteration ${i}: Pattern did not match')
|
||||
println(' Pattern: ${pattern}')
|
||||
println(' Path: ${actual_path}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify all parameters were extracted correctly
|
||||
for name, expected_value in expected_values {
|
||||
if name !in params {
|
||||
println(' Iteration ${i}: Parameter not found: ${name}')
|
||||
return false
|
||||
}
|
||||
|
||||
if params[name] != expected_value {
|
||||
println(' Iteration ${i}: Parameter value mismatch for ${name}')
|
||||
println(' Expected: ${expected_value}')
|
||||
println(' Got: ${params[name]}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test common WebSocket route patterns
|
||||
fn test_property_3_common_patterns() bool {
|
||||
// Test common WebSocket route patterns
|
||||
patterns := ['/ws/:room', '/ws/:room/:user', '/api/ws/:id', '/chat/:room/user/:userId', '/v1/ws/:tenant/:channel']
|
||||
paths := ['/ws/general', '/ws/chat/alice', '/api/ws/12345', '/chat/lobby/user/u123', '/v1/ws/acme/notifications']
|
||||
expected_keys := [['room'], ['room', 'user'], ['id'], ['room', 'userId'], ['tenant', 'channel']]
|
||||
expected_values := [['general'], ['chat', 'alice'], ['12345'], ['lobby', 'u123'], ['acme', 'notifications']]
|
||||
|
||||
for i, pattern in patterns {
|
||||
path := paths[i]
|
||||
keys := expected_keys[i]
|
||||
values := expected_values[i]
|
||||
|
||||
params := match_route_pattern(pattern, path) or {
|
||||
println(' Pattern did not match: ${pattern} vs ${path}')
|
||||
return false
|
||||
}
|
||||
|
||||
for j, name in keys {
|
||||
expected_value := values[j]
|
||||
if name !in params {
|
||||
println(' Parameter not found: ${name}')
|
||||
return false
|
||||
}
|
||||
if params[name] != expected_value {
|
||||
println(' Parameter mismatch for ${name}: expected ${expected_value}, got ${params[name]}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test that non-matching paths don't extract params
|
||||
fn test_property_3_non_matching_paths() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(34567)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate a pattern
|
||||
pattern, _ := generate_random_route_pattern()
|
||||
|
||||
// Generate a completely different path
|
||||
different_pattern, _ := generate_random_route_pattern()
|
||||
mut different_values := map[string]string{}
|
||||
different_param_names := extract_param_names(different_pattern)
|
||||
for name in different_param_names {
|
||||
different_values[name] = generate_random_param_value()
|
||||
}
|
||||
different_path := generate_matching_path(different_pattern, different_values)
|
||||
|
||||
// Try to match - should fail if patterns are different
|
||||
if pattern != different_pattern {
|
||||
result := match_route_pattern(pattern, different_path)
|
||||
// It's okay if it matches by coincidence, but if it does,
|
||||
// the params should still be valid extractions
|
||||
if result != none {
|
||||
// Verify the extracted params are valid (non-empty)
|
||||
for _, value in result {
|
||||
if value.len == 0 {
|
||||
println(' Iteration ${i}: Empty parameter value extracted')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test parameter extraction preserves special characters in values
|
||||
fn test_property_3_special_values() bool {
|
||||
// Test that parameter values with allowed special characters are preserved
|
||||
patterns := ['/ws/:room', '/ws/:room', '/ws/:id', '/chat/:room/:user']
|
||||
paths := ['/ws/room-123', '/ws/room_456', '/ws/ABC123xyz', '/chat/my-room/user_1']
|
||||
expected_keys := [['room'], ['room'], ['id'], ['room', 'user']]
|
||||
expected_values := [['room-123'], ['room_456'], ['ABC123xyz'], ['my-room', 'user_1']]
|
||||
|
||||
for i, pattern in patterns {
|
||||
path := paths[i]
|
||||
keys := expected_keys[i]
|
||||
values := expected_values[i]
|
||||
|
||||
params := match_route_pattern(pattern, path) or {
|
||||
println(' Pattern did not match: ${pattern} vs ${path}')
|
||||
return false
|
||||
}
|
||||
|
||||
for j, name in keys {
|
||||
expected_value := values[j]
|
||||
if params[name] != expected_value {
|
||||
println(' Parameter mismatch for ${name}: expected ${expected_value}, got ${params[name]}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test static routes (no parameters)
|
||||
fn test_property_3_static_routes() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(45678)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate a static route (no parameters)
|
||||
num_segments := rand.int_in_range(1, 5) or { 2 }
|
||||
mut segments := []string{}
|
||||
for _ in 0 .. num_segments {
|
||||
segments << generate_random_static_segment()
|
||||
}
|
||||
pattern := '/' + segments.join('/')
|
||||
|
||||
// Match against itself
|
||||
params := match_route_pattern(pattern, pattern) or {
|
||||
println(' Iteration ${i}: Static pattern did not match itself')
|
||||
println(' Pattern: ${pattern}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Static routes should have no parameters
|
||||
if params.len != 0 {
|
||||
println(' Iteration ${i}: Static route should have no params')
|
||||
println(' Got: ${params}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Test parameter count matches
|
||||
fn test_property_3_param_count() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(56789)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate a pattern with known number of parameters
|
||||
pattern, param_names := generate_random_route_pattern()
|
||||
|
||||
// Generate values
|
||||
mut values := map[string]string{}
|
||||
for name in param_names {
|
||||
values[name] = generate_random_param_value()
|
||||
}
|
||||
|
||||
actual_path := generate_matching_path(pattern, values)
|
||||
|
||||
params := match_route_pattern(pattern, actual_path) or {
|
||||
// If no params in pattern, this is expected for static routes
|
||||
if param_names.len == 0 {
|
||||
continue
|
||||
}
|
||||
println(' Iteration ${i}: Pattern did not match')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify param count matches
|
||||
if params.len != param_names.len {
|
||||
println(' Iteration ${i}: Param count mismatch')
|
||||
println(' Expected: ${param_names.len}')
|
||||
println(' Got: ${params.len}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket Route Pattern Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 3: Route Pattern Support
|
||||
// Validates: Requirements 1.5
|
||||
stats.run_property_test('Property 3.1: Single Parameter Extraction', test_property_3_single_param)
|
||||
stats.run_property_test('Property 3.2: Multiple Parameters Extraction', test_property_3_multiple_params)
|
||||
stats.run_property_test('Property 3.3: Common WebSocket Patterns', test_property_3_common_patterns)
|
||||
stats.run_property_test('Property 3.4: Non-Matching Paths', test_property_3_non_matching_paths)
|
||||
stats.run_property_test('Property 3.5: Special Characters in Values', test_property_3_special_values)
|
||||
stats.run_property_test('Property 3.6: Static Routes', test_property_3_static_routes)
|
||||
stats.run_property_test('Property 3.7: Parameter Count Matches', test_property_3_param_count)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// test_websocket_subprotocol.v - Property test for WebSocket subprotocol negotiation
|
||||
// Feature: websocket-helper, Property 9: Subprotocol Negotiation
|
||||
// Validates: Requirements 4.4
|
||||
//
|
||||
// *For any* WebSocket handshake where the client requests subprotocols via
|
||||
// Sec-WebSocket-Protocol header and the server has matching protocols configured,
|
||||
// the response SHALL include exactly one matching protocol in the
|
||||
// Sec-WebSocket-Protocol response header.
|
||||
module main
|
||||
|
||||
import rand
|
||||
import time
|
||||
import net.http
|
||||
|
||||
const test_iterations = 100
|
||||
|
||||
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 Subprotocol Negotiation 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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Subprotocol Negotiation Functions (copied from websocket.v for testing)
|
||||
// ============================================================================
|
||||
|
||||
// negotiate_subprotocol_from_header - Negotiate subprotocol from client header value
|
||||
// This is a simplified version that takes the header value directly
|
||||
fn negotiate_subprotocol_from_header(client_protocols_header string, supported_protocols []string) string {
|
||||
if supported_protocols.len == 0 {
|
||||
return ''
|
||||
}
|
||||
|
||||
if client_protocols_header.len == 0 {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Parse comma-separated list
|
||||
requested := client_protocols_header.split(',').map(it.trim_space())
|
||||
|
||||
// Find first matching protocol
|
||||
for proto in requested {
|
||||
if proto in supported_protocols {
|
||||
return proto
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// create_handshake_response - Create WebSocket handshake response
|
||||
fn create_handshake_response(key string, protocol string) http.Response {
|
||||
mut headers := http.new_header()
|
||||
headers.add_custom('Upgrade', 'websocket') or {}
|
||||
headers.add_custom('Connection', 'Upgrade') or {}
|
||||
headers.add_custom('Sec-WebSocket-Accept', key) or {}
|
||||
|
||||
if protocol.len > 0 {
|
||||
headers.add_custom('Sec-WebSocket-Protocol', protocol) or {}
|
||||
}
|
||||
|
||||
return http.Response{
|
||||
status_code: 101
|
||||
header: headers
|
||||
body: ''
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Random Data Generators
|
||||
// ============================================================================
|
||||
|
||||
fn generate_random_protocol_name() string {
|
||||
// Generate protocol names like: chat, graphql-ws, mqtt, soap, wamp, etc.
|
||||
prefixes := ['chat', 'graphql', 'mqtt', 'soap', 'wamp', 'stomp', 'xmpp', 'json', 'binary', 'proto']
|
||||
suffixes := ['', '-ws', '-v1', '-v2', '.subprotocol', '-rpc', '-stream']
|
||||
|
||||
prefix := prefixes[rand.int_in_range(0, prefixes.len) or { 0 }]
|
||||
suffix := suffixes[rand.int_in_range(0, suffixes.len) or { 0 }]
|
||||
|
||||
return prefix + suffix
|
||||
}
|
||||
|
||||
fn generate_random_protocol_list(count int) []string {
|
||||
mut protocols := []string{cap: count}
|
||||
for _ in 0 .. count {
|
||||
proto := generate_random_protocol_name()
|
||||
if proto !in protocols {
|
||||
protocols << proto
|
||||
}
|
||||
}
|
||||
return protocols
|
||||
}
|
||||
|
||||
fn protocols_to_header(protocols []string) string {
|
||||
return protocols.join(', ')
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Property 9: Subprotocol Negotiation
|
||||
// Feature: websocket-helper, Property 9: Subprotocol Negotiation
|
||||
// Validates: Requirements 4.4
|
||||
//
|
||||
// *For any* WebSocket handshake where the client requests subprotocols via
|
||||
// Sec-WebSocket-Protocol header and the server has matching protocols configured,
|
||||
// the response SHALL include exactly one matching protocol in the
|
||||
// Sec-WebSocket-Protocol response header.
|
||||
// ============================================================================
|
||||
|
||||
fn test_property_9_matching_protocol_selected() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(12345)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate server's supported protocols (1-5 protocols)
|
||||
server_count := rand.int_in_range(1, 6) or { 2 }
|
||||
server_protocols := generate_random_protocol_list(server_count)
|
||||
|
||||
// Generate client's requested protocols (1-5 protocols)
|
||||
client_count := rand.int_in_range(1, 6) or { 2 }
|
||||
mut client_protocols := generate_random_protocol_list(client_count)
|
||||
|
||||
// Ensure at least one matching protocol by adding one from server list
|
||||
matching_idx := rand.int_in_range(0, server_protocols.len) or { 0 }
|
||||
matching_protocol := server_protocols[matching_idx]
|
||||
|
||||
// Insert matching protocol at random position in client list
|
||||
insert_pos := rand.int_in_range(0, client_protocols.len + 1) or { 0 }
|
||||
client_protocols.insert(insert_pos, matching_protocol)
|
||||
|
||||
// Create header string
|
||||
client_header := protocols_to_header(client_protocols)
|
||||
|
||||
// Negotiate
|
||||
result := negotiate_subprotocol_from_header(client_header, server_protocols)
|
||||
|
||||
// Verify exactly one protocol is returned
|
||||
if result.len == 0 {
|
||||
println(' Iteration ${i}: Expected a matching protocol to be selected')
|
||||
println(' Server protocols: ${server_protocols}')
|
||||
println(' Client protocols: ${client_protocols}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify the selected protocol is in both lists
|
||||
if result !in server_protocols {
|
||||
println(' Iteration ${i}: Selected protocol not in server list')
|
||||
println(' Selected: ${result}')
|
||||
println(' Server protocols: ${server_protocols}')
|
||||
return false
|
||||
}
|
||||
|
||||
if result !in client_protocols {
|
||||
println(' Iteration ${i}: Selected protocol not in client list')
|
||||
println(' Selected: ${result}')
|
||||
println(' Client protocols: ${client_protocols}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_9_no_match_returns_empty() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(54321)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate completely different protocol lists
|
||||
server_protocols := ['server-proto-a', 'server-proto-b', 'server-proto-c']
|
||||
client_protocols := ['client-proto-x', 'client-proto-y', 'client-proto-z']
|
||||
|
||||
client_header := protocols_to_header(client_protocols)
|
||||
|
||||
// Negotiate
|
||||
result := negotiate_subprotocol_from_header(client_header, server_protocols)
|
||||
|
||||
// Verify no protocol is selected when there's no match
|
||||
if result.len > 0 {
|
||||
println(' Iteration ${i}: Expected empty result when no match')
|
||||
println(' Server protocols: ${server_protocols}')
|
||||
println(' Client protocols: ${client_protocols}')
|
||||
println(' Got: ${result}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_9_first_match_selected() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(99999)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Server supports multiple protocols
|
||||
server_protocols := ['proto-a', 'proto-b', 'proto-c', 'proto-d']
|
||||
|
||||
// Client requests protocols in specific order with multiple matches
|
||||
// The first matching protocol in client's list should be selected
|
||||
client_protocols := ['proto-c', 'proto-a', 'proto-d']
|
||||
|
||||
client_header := protocols_to_header(client_protocols)
|
||||
|
||||
// Negotiate
|
||||
result := negotiate_subprotocol_from_header(client_header, server_protocols)
|
||||
|
||||
// Verify the first matching protocol (proto-c) is selected
|
||||
if result != 'proto-c' {
|
||||
println(' Iteration ${i}: Expected first matching protocol to be selected')
|
||||
println(' Expected: proto-c')
|
||||
println(' Got: ${result}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
fn test_property_9_response_includes_protocol() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(77777)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Generate a random protocol
|
||||
protocol := generate_random_protocol_name()
|
||||
|
||||
// Create handshake response with protocol
|
||||
response := create_handshake_response('dummy-accept-key', protocol)
|
||||
|
||||
// Verify response includes the protocol header
|
||||
proto_header := response.header.get_custom('Sec-WebSocket-Protocol') or { '' }
|
||||
|
||||
if proto_header != protocol {
|
||||
println(' Iteration ${i}: Response should include Sec-WebSocket-Protocol header')
|
||||
println(' Expected: ${protocol}')
|
||||
println(' Got: ${proto_header}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify status code is 101
|
||||
if response.status_code != 101 {
|
||||
println(' Iteration ${i}: Response status should be 101')
|
||||
println(' Got: ${response.status_code}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_9_empty_protocol_not_in_response() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(11111)])
|
||||
|
||||
for _ in 0 .. test_iterations {
|
||||
// Create handshake response without protocol
|
||||
response := create_handshake_response('dummy-accept-key', '')
|
||||
|
||||
// Verify response does NOT include the protocol header
|
||||
proto_header := response.header.get_custom('Sec-WebSocket-Protocol') or { '' }
|
||||
|
||||
if proto_header.len > 0 {
|
||||
println(' Response should not include Sec-WebSocket-Protocol when empty')
|
||||
println(' Got: ${proto_header}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_9_empty_server_protocols() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(22222)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Client requests protocols but server has none configured
|
||||
client_protocols := generate_random_protocol_list(3)
|
||||
client_header := protocols_to_header(client_protocols)
|
||||
|
||||
// Empty server protocols
|
||||
server_protocols := []string{}
|
||||
|
||||
// Negotiate
|
||||
result := negotiate_subprotocol_from_header(client_header, server_protocols)
|
||||
|
||||
// Should return empty when server has no protocols
|
||||
if result.len > 0 {
|
||||
println(' Iteration ${i}: Expected empty when server has no protocols')
|
||||
println(' Got: ${result}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_9_empty_client_header() bool {
|
||||
rand.seed([u32(time.now().unix()), u32(33333)])
|
||||
|
||||
for i in 0 .. test_iterations {
|
||||
// Server has protocols but client sends empty header
|
||||
server_protocols := generate_random_protocol_list(3)
|
||||
|
||||
// Negotiate with empty client header
|
||||
result := negotiate_subprotocol_from_header('', server_protocols)
|
||||
|
||||
// Should return empty when client sends no protocols
|
||||
if result.len > 0 {
|
||||
println(' Iteration ${i}: Expected empty when client sends no protocols')
|
||||
println(' Got: ${result}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn test_property_9_whitespace_handling() bool {
|
||||
// Test that whitespace around protocol names is handled correctly
|
||||
server_protocols := ['chat', 'graphql-ws', 'mqtt']
|
||||
|
||||
// Various whitespace formats
|
||||
test_cases := [
|
||||
'chat, graphql-ws, mqtt', // Standard spacing
|
||||
'chat,graphql-ws,mqtt', // No spaces
|
||||
' chat , graphql-ws , mqtt ', // Extra spaces
|
||||
'chat ,graphql-ws, mqtt', // Mixed spacing
|
||||
]
|
||||
|
||||
for i, client_header in test_cases {
|
||||
result := negotiate_subprotocol_from_header(client_header, server_protocols)
|
||||
|
||||
// Should find 'chat' as first match regardless of whitespace
|
||||
if result != 'chat' {
|
||||
println(' Test case ${i}: Whitespace handling failed')
|
||||
println(' Header: "${client_header}"')
|
||||
println(' Expected: chat')
|
||||
println(' Got: ${result}')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println('🚀 Starting WebSocket Subprotocol Negotiation Property Tests...')
|
||||
println('Each property test runs ${test_iterations} iterations\n')
|
||||
|
||||
mut stats := PropertyTestStats{}
|
||||
|
||||
// Run property tests
|
||||
// Feature: websocket-helper, Property 9: Subprotocol Negotiation
|
||||
// Validates: Requirements 4.4
|
||||
stats.run_property_test('Property 9: Matching Protocol Selected', test_property_9_matching_protocol_selected)
|
||||
stats.run_property_test('Property 9: No Match Returns Empty', test_property_9_no_match_returns_empty)
|
||||
stats.run_property_test('Property 9: First Match Selected', test_property_9_first_match_selected)
|
||||
stats.run_property_test('Property 9: Response Includes Protocol', test_property_9_response_includes_protocol)
|
||||
stats.run_property_test('Property 9: Empty Protocol Not In Response', test_property_9_empty_protocol_not_in_response)
|
||||
stats.run_property_test('Property 9: Empty Server Protocols', test_property_9_empty_server_protocols)
|
||||
stats.run_property_test('Property 9: Empty Client Header', test_property_9_empty_client_header)
|
||||
stats.run_property_test('Property 9: Whitespace Handling', test_property_9_whitespace_handling)
|
||||
|
||||
// Print test summary
|
||||
stats.print_summary()
|
||||
}
|
||||
Reference in New Issue
Block a user