# 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::<mlua::Value>("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::<mlua::Value>("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.
