Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ chrono = "0.4"
dirs = "6"
libc = "0.2"
sha1_smol = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[dev-dependencies]
tempfile = "3"
35 changes: 33 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ enum Commands {
#[arg(long)]
service: Option<String>,
/// Compact mode: show only binary name instead of full path
#[arg(short, long)]
#[arg(short, long, conflicts_with = "json")]
compact: bool,
/// Output as JSON array
#[arg(long)]
json: bool,
},
/// Grant a TCC permission (inserts new entry)
Grant {
Expand Down Expand Up @@ -204,10 +207,12 @@ mod tests {
client,
service,
compact,
json,
} => {
assert_eq!(client.as_deref(), Some("apple"));
assert_eq!(service.as_deref(), Some("Camera"));
assert!(!compact);
assert!(!json);
}
_ => panic!("expected List"),
}
Expand All @@ -222,6 +227,15 @@ mod tests {
}
}

#[test]
fn parse_list_json() {
let cli = parse(&["tcc", "list", "--json"]).unwrap();
match cli.command {
Commands::List { json, .. } => assert!(json),
_ => panic!("expected List"),
}
}

#[test]
fn parse_services() {
let cli = parse(&["tcc", "services"]).unwrap();
Expand Down Expand Up @@ -359,6 +373,12 @@ mod tests {
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
}

#[test]
fn parse_list_compact_and_json_conflict() {
let err = parse(&["tcc", "list", "--compact", "--json"]).unwrap_err();
assert_eq!(err.kind(), ErrorKind::ArgumentConflict);
}

#[test]
fn cli_has_version() {
let cmd = Cli::command();
Expand Down Expand Up @@ -401,10 +421,21 @@ fn main() {
client,
service,
compact,
json,
} => {
let db = make_db(target);
match db.list(client.as_deref(), service.as_deref()) {
Ok(entries) => print_entries(&entries, compact),
Ok(entries) => {
if json {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: --compact is silently ignored when --json is used

When a user passes both --json and --compact, the compact flag is destructured but never consulted in the JSON branch. The user gets no feedback that --compact had no effect. Consider either:

  • Emitting a warning to stderr (e.g. eprintln!("Warning: --compact has no effect with --json")), or
  • Making the two flags mutually exclusive via a clap conflicts_with attribute on the --compact arg.

println!(
"{}",
serde_json::to_string_pretty(&entries)
.expect("failed to serialize entries")
);
} else {
print_entries(&entries, compact);
}
}
Err(e) => {
eprintln!("{}: {}", "Error".red().bold(), e);
process::exit(1);
Expand Down
3 changes: 2 additions & 1 deletion src/tcc.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use chrono::{Local, TimeZone};
use rusqlite::{Connection, OpenFlags};
use serde::Serialize;
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -109,7 +110,7 @@ impl fmt::Display for TccError {
}
}

#[derive(Debug)]
#[derive(Debug, Serialize)]
pub struct TccEntry {
pub service_raw: String,
pub service_display: String,
Expand Down
92 changes: 92 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use serde_json::Value;
use std::process::Command;

/// Helper: run the `tccutil-rs` binary with given args, returning (stdout, stderr, success).
Expand Down Expand Up @@ -129,3 +130,94 @@ fn version_flag_prints_version() {
"version output should mention tccutil-rs"
);
}

// ── tccutil-rs list --json ──────────────────────────────────────────

const EXPECTED_JSON_FIELDS: &[&str] = &[
"service_raw",
"service_display",
"client",
"auth_value",
"last_modified",
"is_system",
];

#[test]
fn list_json_outputs_valid_json_array() {
let (stdout, _stderr, success) = run_tcc(&["--user", "list", "--json"]);
assert!(success, "tccutil-rs --user list --json should exit 0");

// Always assert: output is valid JSON and is an array
let parsed: Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Field-presence assertions are vacuously skipped when the DB is empty

If the user TCC database has no entries (common in CI), arr is empty and the for (i, entry) in arr.iter().enumerate() loop never runs. The EXPECTED_JSON_FIELDS check is therefore never exercised, so the test only verifies that the output is a valid JSON array — not that each entry contains the required fields.

Consider adding a guard or using a fixture DB to ensure at least one entry is present when the field-structure assertions need to run.

let arr = parsed.as_array().expect("JSON output should be an array");

// Every element must be an object with the expected fields
for (i, entry) in arr.iter().enumerate() {
assert!(
entry.is_object(),
"entry at index {} should be an object",
i
);
for field in EXPECTED_JSON_FIELDS {
assert!(
entry.get(field).is_some(),
"entry at index {} missing field '{}'",
i,
field
);
}
}
}

#[test]
fn list_json_service_filter_returns_valid_structure() {
// Use a service that almost certainly exists (Accessibility is one of the oldest TCC services).
// Even if zero rows match, the output must still be a valid JSON array.
let (stdout, _stderr, success) =
run_tcc(&["--user", "list", "--json", "--service", "Accessibility"]);
assert!(success);

let parsed: Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
let arr = parsed.as_array().expect("JSON output should be an array");

for (i, entry) in arr.iter().enumerate() {
assert!(entry.is_object(), "entry {} should be an object", i);
assert!(
entry.get("service_raw").is_some(),
"entry {} missing service_raw",
i
);
}
}

#[test]
fn list_compact_and_json_conflict() {
let (_stdout, stderr, success) = run_tcc(&["--user", "list", "--compact", "--json"]);
assert!(!success, "passing both --compact and --json should fail");
assert!(
stderr.contains("cannot be used with"),
"clap should report argument conflict, got: {}",
stderr
);
}

#[test]
fn list_json_with_client_filter_only_contains_matching_entries() {
let (stdout, _stderr, success) = run_tcc(&["--user", "list", "--json", "--client", "apple"]);
assert!(
success,
"tccutil-rs --user list --json --client apple should exit 0"
);

let parsed: Value = serde_json::from_str(&stdout).expect("output should be valid JSON");
let arr = parsed.as_array().expect("should be an array");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Vacuous test — filter correctness is never verified when the DB is empty

If no TCC entries match --client apple (e.g. in a CI environment without a real TCC database), arr will be an empty slice and the for entry in arr loop at line 215 never executes. The assertion inside the loop is therefore never evaluated, so the test passes without actually verifying that the client filter works correctly.

Consider adding an explicit check:

// Ensure the test is meaningful only when entries exist
if !arr.is_empty() {
    for entry in arr {
        let client = entry["client"].as_str().expect("client should be a string");
        assert!(
            client.to_lowercase().contains("apple"),
            "filtered entry should contain 'apple', got: {}",
            client
        );
    }
}
// Or document the vacuous-pass behaviour explicitly:
// assert!(arr.is_empty() || arr.iter().all(|e| ...));

Alternatively, use a fixture/mock DB so the test always has data to validate against.


for entry in arr {
let client = entry["client"].as_str().expect("client should be a string");
assert!(
client.to_lowercase().contains("apple"),
"filtered entry should contain 'apple', got: {}",
client
);
}
}