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:
meiseayoung
2026-01-04 13:38:12 +08:00
parent 4e2c6a90c3
commit 6ab6d3b99e
22 changed files with 8545 additions and 4 deletions
+583
View File
@@ -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()
}
+418
View File
@@ -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()
}
+451
View File
@@ -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()
}
+387
View File
@@ -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()
}
+428
View File
@@ -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()
}
+273
View File
@@ -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()
}
+400
View File
@@ -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()
}
+384
View File
@@ -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()
}
+534
View File
@@ -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()
}
+578
View File
@@ -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()
}
+475
View File
@@ -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()
}
+392
View File
@@ -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()
}