1use std::io::BufRead;
2use std::sync::Arc;
3
4use clap::Subcommand;
5
6use crate::admin;
7use crate::audit::ALL_AUDIT_EVENTS;
8use crate::cli::CliError;
9use crate::cli::render;
10use crate::cli::style::Palette;
11use crate::sqlite::audit::AuditQuery;
12use crate::sqlite::db::Database;
13
14const DEFAULT_LIMIT: i64 = 50;
22
23#[derive(Subcommand)]
24pub enum AuditCommand {
25 List {
27 #[arg(long)]
28 profile: Option<String>,
29 #[arg(long = "account-id")]
30 account_id: Option<String>,
31 #[arg(long = "order-id")]
32 order_id: Option<String>,
33 #[arg(long = "cert-serial")]
34 cert_serial: Option<String>,
35 #[arg(long)]
38 event: Option<String>,
39 #[arg(long)]
41 outcome: Option<String>,
42 #[arg(long = "since-days")]
44 since_days: Option<u64>,
45 #[arg(long, default_value_t = DEFAULT_LIMIT)]
46 limit: i64,
47 #[arg(long, default_value_t = 0)]
48 offset: i64,
49 #[arg(long)]
50 json: bool,
51 },
52 Show {
54 id: i64,
55 #[arg(long)]
56 json: bool,
57 },
58 Cleanup {
63 #[arg(long = "older-than")]
64 older_than: u64,
65 },
66}
67
68fn check_filters(event: Option<&str>, outcome: Option<&str>) -> Result<(), CliError> {
74 if let Some(event) = event
75 && crate::audit::AuditEvent::parse(event).is_none()
76 {
77 let known: Vec<&str> = ALL_AUDIT_EVENTS.iter().map(|e| e.as_str()).collect();
78 return Err(CliError(format!(
79 "unknown --event `{event}`; known events are {}",
80 known.join(", ")
81 )));
82 }
83 if let Some(outcome) = outcome
84 && !matches!(outcome, "success" | "failure")
85 {
86 return Err(CliError(format!(
87 "unknown --outcome `{outcome}`; expected `success` or `failure`"
88 )));
89 }
90 Ok(())
91}
92
93pub async fn run_audit_command(
94 command: AuditCommand,
95 yes: bool,
96 palette: Palette,
97 reader: &mut impl BufRead,
98 database: Arc<Database>,
99) -> Result<(), CliError> {
100 match command {
101 AuditCommand::List {
102 profile,
103 account_id,
104 order_id,
105 cert_serial,
106 event,
107 outcome,
108 since_days,
109 limit,
110 offset,
111 json,
112 } => {
113 check_filters(event.as_deref(), outcome.as_deref())?;
114 let query = AuditQuery {
115 profile,
116 account_id,
117 order_id,
118 cert_serial,
119 event,
120 outcome,
121 since: since_days.map(admin::audit_cutoff),
122 limit: limit.max(1),
123 offset: offset.max(0),
124 };
125 let (entries, total) = admin::list_audit(&query, database).await?;
126 if json {
127 let rows: Vec<_> = entries
128 .iter()
129 .map(crate::sqlite::audit::AuditEntry::to_json)
130 .collect();
131 println!("{}", serde_json::json!({ "total": total, "entries": rows }));
132 } else {
133 for entry in &entries {
134 println!("{}", render::render_audit_line(entry, palette));
135 }
136 println!("{} of {total} row(s).", entries.len());
140 }
141 }
142 AuditCommand::Show { id, json } => {
143 let Some(entry) = admin::find_audit(id, database).await? else {
144 return Err(CliError(format!("audit row {id} not found")));
145 };
146 if json {
147 println!("{}", entry.to_json());
148 } else {
149 print!("{}", render::render_audit_detail_text(&entry, palette));
150 }
151 }
152 AuditCommand::Cleanup { older_than } => {
153 match admin::confirm_cleanup_audit(older_than, yes, reader, database).await? {
154 None => println!("Cancelled."),
155 Some(removed) => println!("Removed {removed} audit row(s)."),
156 }
157 }
158 }
159 Ok(())
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use acme_proxy_self::audit::{Actor, AuditRecord};
166 use acme_proxy_self::sqlite::audit::AuditEntry;
167 use acme_proxy_self::sqlite::db::Database;
168
169 use crate as acme_proxy_self;
172
173 async fn db_with_rows() -> Arc<Database> {
174 let db = Arc::new(Database::connect_in_memory().await.unwrap());
175 for event in ALL_AUDIT_EVENTS {
176 AuditEntry::insert(
177 AuditRecord::new(*event, "default", Actor::acme("acct-1"))
178 .with_account("acct-1")
179 .with_serial("0a0b"),
180 &db,
181 )
182 .await
183 .unwrap();
184 }
185 db
186 }
187
188 #[test]
192 fn an_unknown_event_or_outcome_is_refused_by_name() {
193 assert!(check_filters(None, None).is_ok());
194 assert!(check_filters(Some("certificate_issued"), Some("success")).is_ok());
195
196 let error = check_filters(Some("certificate_renewed"), None).unwrap_err();
197 assert!(error.0.contains("certificate_renewed"), "{error}");
198 assert!(error.0.contains("certificate_issued"), "{error}");
201 assert!(error.0.contains("certificate_revoke_failed"), "{error}");
202
203 let error = check_filters(None, Some("maybe")).unwrap_err();
204 assert!(error.0.contains("maybe"), "{error}");
205 assert!(error.0.contains("success"), "{error}");
206 }
207
208 fn list(json: bool) -> AuditCommand {
211 AuditCommand::List {
212 profile: None,
213 account_id: None,
214 order_id: None,
215 cert_serial: None,
216 event: None,
217 outcome: None,
218 since_days: None,
219 limit: DEFAULT_LIMIT,
220 offset: 0,
221 json,
222 }
223 }
224
225 fn list_window(limit: i64, offset: i64) -> AuditCommand {
226 AuditCommand::List {
227 profile: None,
228 account_id: None,
229 order_id: None,
230 cert_serial: None,
231 event: None,
232 outcome: None,
233 since_days: None,
234 limit,
235 offset,
236 json: false,
237 }
238 }
239
240 fn list_event(event: &str) -> AuditCommand {
241 AuditCommand::List {
242 profile: None,
243 account_id: None,
244 order_id: None,
245 cert_serial: None,
246 event: Some(event.to_string()),
247 outcome: None,
248 since_days: None,
249 limit: DEFAULT_LIMIT,
250 offset: 0,
251 json: false,
252 }
253 }
254
255 fn list_every_filter() -> AuditCommand {
258 AuditCommand::List {
259 profile: Some("default".to_string()),
260 account_id: Some("acct-1".to_string()),
261 order_id: Some("order-1".to_string()),
262 cert_serial: Some("0a0b".to_string()),
263 event: Some("certificate_issued".to_string()),
264 outcome: Some("success".to_string()),
265 since_days: Some(7),
266 limit: DEFAULT_LIMIT,
267 offset: 0,
268 json: false,
269 }
270 }
271
272 #[tokio::test]
276 async fn list_runs_in_both_shapes_and_clamps_a_nonsense_window() {
277 let db = db_with_rows().await;
278 let mut reader: &[u8] = &[];
279
280 run_audit_command(list(false), true, Palette::plain(), &mut reader, db.clone())
281 .await
282 .unwrap();
283 run_audit_command(list(true), true, Palette::plain(), &mut reader, db.clone())
284 .await
285 .unwrap();
286 run_audit_command(
287 list_window(0, -5),
288 true,
289 Palette::plain(),
290 &mut reader,
291 db.clone(),
292 )
293 .await
294 .unwrap();
295 run_audit_command(list_every_filter(), true, Palette::plain(), &mut reader, db)
296 .await
297 .unwrap();
298 }
299
300 #[tokio::test]
303 async fn list_refuses_an_unknown_event_before_querying() {
304 let db = Arc::new(Database::connect_in_memory().await.unwrap());
305 let mut reader: &[u8] = &[];
306 let error = run_audit_command(list_event("nope"), true, Palette::plain(), &mut reader, db)
307 .await
308 .unwrap_err();
309 assert!(error.0.contains("unknown --event"), "{error}");
310 }
311
312 #[tokio::test]
313 async fn show_renders_both_shapes_and_names_an_unknown_id() {
314 let db = db_with_rows().await;
315 let mut reader: &[u8] = &[];
316
317 for json in [false, true] {
318 run_audit_command(
319 AuditCommand::Show { id: 1, json },
320 true,
321 Palette::plain(),
322 &mut reader,
323 db.clone(),
324 )
325 .await
326 .unwrap();
327 }
328
329 let error = run_audit_command(
330 AuditCommand::Show {
331 id: 9_999,
332 json: false,
333 },
334 true,
335 Palette::plain(),
336 &mut reader,
337 db,
338 )
339 .await
340 .unwrap_err();
341 assert!(error.0.contains("9999"), "{error}");
342 }
343
344 #[tokio::test]
346 async fn cleanup_honours_the_prompt() {
347 let db = db_with_rows().await;
348
349 let mut declined: &[u8] = b"n\n";
350 run_audit_command(
351 AuditCommand::Cleanup { older_than: 0 },
352 false,
353 Palette::plain(),
354 &mut declined,
355 db.clone(),
356 )
357 .await
358 .unwrap();
359 assert_eq!(
360 AuditEntry::count_older_than(i64::MAX, &db).await.unwrap(),
361 4
362 );
363
364 let mut reader: &[u8] = &[];
367 run_audit_command(
368 AuditCommand::Cleanup { older_than: 365 },
369 true,
370 Palette::plain(),
371 &mut reader,
372 db.clone(),
373 )
374 .await
375 .unwrap();
376 assert_eq!(
377 AuditEntry::count_older_than(i64::MAX, &db).await.unwrap(),
378 4
379 );
380 }
381}