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