Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions crates/clx-core/src/llm/azure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -547,4 +547,69 @@ mod tests {
.unwrap();
assert!(!backend.is_available().await);
}

/// TC-AZ-013 — Dated URL shape: when `api_version` is set, URL builders
/// switch to `/openai/deployments/<deployment>/...?api-version=<v>`.
/// Default (None) uses the v1 path. Pure URL-construction assertion;
/// no mock needed.
#[test]
#[serial(env_azure_hosts)]
fn dated_url_shape_when_api_version_set() {
allow_local();
let mut c = cfg("http://127.0.0.1:9999".to_string());
c.api_version = Some("2024-10-21".to_string());
let backend =
AzureOpenAIBackend::new(&c, SecretString::new("k".to_string().into())).unwrap();

let chat = backend.chat_url("gpt-5.4-mini");
assert_eq!(
chat.path(),
"/openai/deployments/gpt-5.4-mini/chat/completions"
);
assert_eq!(chat.query(), Some("api-version=2024-10-21"));

let embed = backend.embeddings_url("text-embedding-3-small");
assert_eq!(
embed.path(),
"/openai/deployments/text-embedding-3-small/embeddings"
);
assert_eq!(embed.query(), Some("api-version=2024-10-21"));

let models = backend.models_url();
assert_eq!(models.path(), "/openai/models");
assert_eq!(models.query(), Some("api-version=2024-10-21"));
}

/// TC-AZ-013 (companion) — Default v1 path when `api_version` is None.
#[test]
#[serial(env_azure_hosts)]
fn v1_url_shape_when_api_version_unset() {
allow_local();
let backend = AzureOpenAIBackend::new(
&cfg("http://127.0.0.1:9999".to_string()),
SecretString::new("k".to_string().into()),
)
.unwrap();

assert_eq!(backend.chat_url("d").path(), "/openai/v1/chat/completions");
assert!(backend.chat_url("d").query().is_none());
assert_eq!(backend.embeddings_url("d").path(), "/openai/v1/embeddings");
assert_eq!(backend.models_url().path(), "/openai/v1/models");
}

/// TC-CRED-011 — `SecretString::Debug` redacts the secret value.
/// Uses `secrecy` crate's built-in redaction; this test pins the
/// behavior so a future dep update or accidental `Debug` derive
/// addition somewhere downstream cannot leak the value.
#[test]
fn secret_string_debug_is_redacted() {
let s = SecretString::new("super-secret-value-not-to-be-leaked".to_string().into());
let debug_output = format!("{s:?}");
assert!(
!debug_output.contains("super-secret-value-not-to-be-leaked"),
"Debug output must not contain the secret value: got {debug_output:?}"
);
// secrecy crate prints either "Secret(...)" or "[REDACTED]" depending
// on version. Both are acceptable; we just need the value gone.
}
}
124 changes: 124 additions & 0 deletions crates/clx-core/src/storage/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,130 @@ fn test_create_and_get_audit_log() {
assert_eq!(retrieved.risk_score, Some(100));
}

/// TC-AUD-002 — Regression for the 0.7.1 fix: `create_audit_log` must
/// auto-create the referenced session row when it does not exist.
/// Without this guard, fast-path / synthetic / fabricated session IDs
/// trip the `audit_log` → `sessions` FK constraint.
#[test]
fn test_audit_log_auto_creates_missing_session() {
let storage = create_test_storage();

let synthetic_id = "synthetic-session-not-in-table";
// Note: deliberately NOT calling create_session() first.
let entry = AuditLogEntry::new(
SessionId::new(synthetic_id),
"echo hi".to_string(),
"layer0".to_string(),
AuditDecision::Allowed,
);

let id = storage
.create_audit_log(&entry)
.expect("audit log must succeed even when session row does not exist");
assert!(id > 0, "audit log should have a generated ID");

// The session row must now exist (auto-created).
let session = storage
.get_session(synthetic_id)
.expect("get_session call ok")
.expect("session row should have been auto-created");
assert_eq!(session.id, SessionId::new(synthetic_id));
}

/// TC-AUD-003 — Auto-created session has the documented placeholder
/// fields so it's distinguishable from a real session.
#[test]
fn test_audit_log_auto_created_session_has_placeholder_source() {
let storage = create_test_storage();
let synthetic_id = "synthetic-placeholder-check";
let entry = AuditLogEntry::new(
SessionId::new(synthetic_id),
"any cmd".to_string(),
"layer0".to_string(),
AuditDecision::Allowed,
);
storage.create_audit_log(&entry).unwrap();

// Query the raw row to verify the source/status defaults from the
// INSERT OR IGNORE in `create_audit_log`.
let (source, status): (String, String) = storage
.conn
.query_row(
"SELECT source, status FROM sessions WHERE id = ?1",
[synthetic_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.expect("session row exists");
assert_eq!(source, "audit-placeholder");
assert_eq!(status, "active");
}

/// TC-AUD-008 — Privacy property: secrets in the `command` field of an
/// `AuditLogEntry` must be redacted before they hit the persistent
/// audit log table. Without this, every `clx-hook` audit row could
/// archive an API key the user pasted on a CLI invocation.
///
/// Note: redaction happens upstream in `clx_hook::audit::log_audit_entry`
/// (calls `redact_secrets`). This test asserts the redaction pipeline
/// works on a representative input — if `redact_secrets` regresses,
/// production audit rows would silently store cleartext.
#[test]
fn test_audit_command_redaction_pipeline() {
use crate::redaction::redact_secrets;

let raw = "curl -H 'Authorization: Bearer sk-abc123def456ghi789jkl012mno345pq' https://api.example.com";
let redacted = redact_secrets(raw);

assert!(
!redacted.contains("sk-abc123def456ghi789jkl012mno345pq"),
"raw key must not survive redaction: {redacted}"
);
assert!(
redacted.contains("REDACTED") || redacted.contains("***"),
"redacted output should contain a redaction marker: {redacted}"
);
// Round-trip through audit log — verify the SAME redacted form
// round-trips into and out of the table.
let storage = create_test_storage();
let entry = AuditLogEntry::new(
SessionId::new("redaction-test-session"),
redacted.clone(),
"layer0".to_string(),
AuditDecision::Allowed,
);
let id = storage.create_audit_log(&entry).unwrap();
let retrieved = storage.get_audit_log(id).unwrap().unwrap();
assert_eq!(retrieved.command, redacted);
assert!(
!retrieved.command.contains("sk-abc123"),
"audit log row must not contain raw secret"
);
}

/// TC-MIG-006 — `column_exists` is hardened against SQL injection via a
/// `VALID_TABLES` allowlist. Calling with an unsafe table name must
/// return false (not panic, not execute the injected SQL).
#[test]
fn test_column_exists_rejects_unsafe_table_names() {
let storage = create_test_storage();
// Each of these is a classic injection or unknown-table attempt.
let bad_tables = [
"'; DROP TABLE sessions; --",
"sessions; DELETE FROM audit_log;",
"../etc/passwd",
"unknown_table",
"",
];
for bad in bad_tables {
assert!(
!storage.column_exists(bad, "id"),
"column_exists should reject unsafe table name: {bad:?}"
);
}
// Sanity: a known table still works.
assert!(storage.column_exists("sessions", "id"));
}

#[test]
fn test_get_audit_log_by_session() {
let storage = create_test_storage();
Expand Down
Loading