# Test Coverage for Security Fixes ## Context We implemented 30 security fixes across the irc-now workspace. The test suite has 181 tests but they are concentrated in operator resource builders. Many security-critical functions have zero coverage. This plan adds ~55 targeted tests for the functions that guard against the vulnerabilities we fixed. ## Approach Follow existing conventions: inline `#[cfg(test)] mod tests`, simple `assert!`/`assert_eq!`, no new dev-dependencies, no async tests. Focus on pure functions that can be tested without mocking HTTP or database layers. Extract inline logic to named functions where needed. --- ## 1. Operator config sanitization (10 tests) ### `crates/soju-operator/src/resources/configmap.rs` (4 tests) Add to existing `mod tests` block (line 84): - `sanitize_config_value_strips_newlines` -- `"hello\nworld\r"` -> `"helloworld"` - `sanitize_config_value_preserves_clean` -- `"irc.example.com"` unchanged - `sanitize_config_value_empty` -- `""` -> `""` - `config_rejects_hostname_injection` -- create bouncer with hostname `"evil\nhostname-cloak hacker"`, build configmap, assert config does NOT contain `"hostname-cloak hacker"` on its own line (uses existing `test_bouncer()` fixture) ### `crates/ergo-operator/src/resources/configmap.rs` (6 tests) Add to existing `mod tests` block (line 163): - `sanitize_yaml_value_escapes_backslash` -- `"a\\b"` -> `"a\\\\b"` - `sanitize_yaml_value_escapes_quotes` -- `r#"a"b"#` -> `r#"a\"b"#` - `sanitize_yaml_value_strips_newlines` -- `"a\nb\r"` -> `"ab"` - `sanitize_yaml_value_combined` -- `"a\\\"\n"` -> `"a\\\\\\\""` - `sanitize_yaml_value_empty` -- `""` -> `""` - `config_rejects_netname_injection` -- create network with cloaking netname `"evil\"\nnew-key: injected"`, build configmap, assert config does NOT contain `"new-key: injected"` --- ## 2. Input validation functions (9 tests) ### `crates/web-api/src/ergo_admin.rs` (6 tests) No test module exists. Add `#[cfg(test)] mod tests` at end of file: - `validate_irc_param_accepts_alphanumeric` -- `"testuser123"` -> `Ok(())` - `validate_irc_param_accepts_hyphens_underscores` -- `"my-user_1"` -> `Ok(())` - `validate_irc_param_rejects_empty` -- `""` -> `Err` - `validate_irc_param_rejects_too_long` -- 51-char string -> `Err` - `validate_irc_param_rejects_spaces` -- `"user name"` -> `Err` - `validate_irc_param_rejects_newlines` -- `"user\r\nNICK hacker"` -> `Err` ### `crates/web-api/src/k8s.rs` (3 tests) Add to existing `mod tests` block (line 240): - `bouncer_name_truncates_at_63` -- `"a".repeat(100)`, assert `result.len() <= 63` - `bouncer_name_exact_boundary` -- `"a".repeat(63)`, assert `result.len() == 63` - `bouncer_name_all_special_chars` -- `"!!!"`, expect `""` (all mapped to hyphens, then trimmed) --- ## 3. Stripe webhook signature (5 tests) ### `crates/web-api/src/stripe_util.rs` Add to existing `mod tests` block (line 51). Compute a valid HMAC in the test using `hmac` + `hex` (already in Cargo.toml): - `webhook_signature_accepts_valid` -- compute real HMAC of `"1234.payload"` with secret `"whsec_test"`, format as `"t=1234,v1={hex}"`, assert `true` - `webhook_signature_rejects_missing_timestamp` -- `"v1=abcdef"` -> `false` - `webhook_signature_rejects_missing_v1` -- `"t=123456"` -> `false` - `webhook_signature_rejects_invalid_hex` -- `"t=123,v1=zzzz"` -> `false` - `webhook_signature_rejects_empty` -- `""` -> `false` --- ## 4. Lua sandbox (8 tests) ### `crates/bot/src/lua/sandbox.rs` No test module exists. Add `#[cfg(test)] mod tests` at end of file. All synchronous (`create_sandbox()` is not async): - `sandbox_removes_os` -- `globals.get::("os")` is `Nil` - `sandbox_removes_io` -- same for `"io"` - `sandbox_removes_load` -- same for `"load"` - `sandbox_removes_coroutine` -- same for `"coroutine"` - `sandbox_removes_debug` -- same for `"debug"` - `sandbox_preserves_math` -- `globals.get::("math")` is NOT `Nil` - `sandbox_preserves_string` -- same for `"string"` - `sandbox_instruction_limit` -- execute `"while true do end"`, assert error message contains `"instruction limit"` --- ## 5. Image service functions (15 tests) ### `crates/pics/src/routes/image.rs` #### Filename sanitization (5 tests) Extract the inline sanitization at line 225 to a named function: ```rust fn sanitize_filename(input: &str) -> String { input.chars() .map(|c| if c.is_alphanumeric() || c == '.' || c == '_' || c == '-' { c } else { '_' }) .collect() } ``` Replace the inline code with a call to `sanitize_filename(&filename)`. Tests: - `sanitize_filename_preserves_normal` -- `"photo.jpg"` unchanged - `sanitize_filename_replaces_path_traversal` -- `"../../etc/passwd"` -> `"______etc_passwd"` - `sanitize_filename_replaces_spaces` -- `"my photo.png"` -> `"my_photo.png"` - `sanitize_filename_unicode` -- `"\u{1F600}.png"` -> `"_.png"` - `sanitize_filename_empty` -- `""` -> `""` #### Content type detection (6 tests) `detect_content_type` at line 25 is already a standalone function: - `detect_jpeg` -- bytes `[0xFF, 0xD8, 0xFF, 0x00, ...]` (pad to 12) -> `Some("image/jpeg")` - `detect_png` -- bytes `[0x89, 0x50, 0x4E, 0x47, ...]` (pad to 12) -> `Some("image/png")` - `detect_gif` -- bytes `[0x47, 0x49, 0x46, ...]` (pad to 12) -> `Some("image/gif")` - `detect_webp` -- bytes `[0x52, 0x49, 0x46, 0x46, 0,0,0,0, 0x57, 0x45, 0x42, 0x50]` -> `Some("image/webp")` - `detect_unknown` -- 12 zero bytes -> `None` - `detect_too_short` -- 3 bytes -> `None` #### Internal token auth (4 tests) `check_internal_token` at line 449 takes `&HeaderMap`, returns `Result<(), StatusCode>`. Testable with env var setup: - `internal_token_accepts_valid` -- set env, add matching header -> `Ok(())` - `internal_token_rejects_missing_header` -- set env, no header -> `Err(FORBIDDEN)` - `internal_token_rejects_wrong_token` -- set env, wrong header value -> `Err(FORBIDDEN)` - `internal_token_rejects_unset_env` -- env var empty/unset -> `Err(FORBIDDEN)` --- ## 6. Rate limiting (6 tests) ### `crates/pics/src/state.rs` (3 tests) No test module exists. Add one. Constants: FREE=10, PRO=30. - `rate_limit_allows_under_limit` -- empty vec, `is_pro=false` -> `true` - `rate_limit_blocks_at_limit` -- pre-fill 10 recent entries, `is_pro=false` -> `false` - `rate_limit_pro_higher_limit` -- pre-fill 10 entries, `is_pro=true` -> `true` (under 30) ### `crates/txt/src/state.rs` (3 tests) Same pattern. Constants: FREE=20, PRO=60. - `rate_limit_allows_under_limit` -- empty vec -> `true` - `rate_limit_blocks_at_limit` -- pre-fill 20 entries -> `false` - `rate_limit_pro_higher_limit` -- pre-fill 20 entries, `is_pro=true` -> `true` --- ## 7. SQL quoting (2 tests) ### `crates/soju-operator/src/db.rs` Add to existing `mod tests` block: - `quote_literal_escapes_single_quotes` -- `"it's"` -> `"'it''s'"` - `quote_literal_no_quotes` -- `"simple"` -> `"'simple'"` --- ## Summary | File | New Tests | What's covered | |------|-----------|----------------| | soju-operator configmap.rs | 4 | config value sanitization, injection prevention | | ergo-operator configmap.rs | 6 | YAML value sanitization, injection prevention | | web-api ergo_admin.rs | 6 | IRC parameter validation | | web-api k8s.rs | 3 | DNS label length truncation | | web-api stripe_util.rs | 5 | valid signature path, edge cases | | bot sandbox.rs | 8 | globals removal, instruction limit | | pics image.rs | 15 | filename sanitization, content detection, internal token auth | | pics state.rs | 3 | rate limiting | | txt state.rs | 3 | rate limiting | | soju-operator db.rs | 2 | SQL quoting | | **Total** | **55** | | 181 existing + 55 new = **236 tests** ## Execution order 1. Operator config sanitization (#1) -- zero risk, extends existing test suites 2. Input validation + SQL quoting (#2, #7) -- pure functions, some need new test modules 3. Stripe webhook (#3) -- needs HMAC computation in test 4. Pics/txt functions (#5, #6) -- extract filename sanitization, env var tests for token 5. Lua sandbox (#4) -- exercises mlua runtime ## Verification ```bash cargo test 2>&1 | grep -E "test result:|FAILED" ``` All existing 181 tests must continue passing. New tests should add ~55 to the total. No new dev-dependencies required.