1use std::collections::{BTreeMap, HashSet};
38use std::path::{Path, PathBuf};
39
40#[cfg(any(feature = "auth-import", test))]
45use cratefield_core::HttpClient;
46use serde::{Deserialize, Serialize};
47use serde_json::Value;
48#[cfg(any(feature = "auth-import", test))]
49use std::sync::Arc;
50
51pub const IMPORT_PATH: &str = "/v1/auth-core/admin/users/import";
59
60pub const DEFAULT_BATCH_SIZE: usize = 500;
62
63pub const MAX_BATCH_SIZE: usize = 1000;
66
67const STATUSES: [&str; 5] = ["created", "unchanged", "merged", "conflict", "invalid"];
69
70pub struct ImportOptions {
74 pub file: PathBuf,
76 pub target: String,
78 pub admin_token: String,
81 pub apply: bool,
83 pub merge_by_email: bool,
86 pub report: Option<PathBuf>,
89 pub batch_size: usize,
91}
92
93impl ImportOptions {
94 #[must_use]
97 pub fn dry_run(&self) -> bool {
98 !self.apply
99 }
100
101 #[must_use]
103 pub fn report_path(&self) -> PathBuf {
104 if let Some(path) = &self.report {
105 return path.clone();
106 }
107 let mut name = self.file.clone().into_os_string();
108 name.push(".report.jsonl");
109 PathBuf::from(name)
110 }
111}
112
113#[derive(Debug, Serialize, Deserialize)]
117struct ResultRow {
118 external_provider: String,
119 external_id: String,
120 status: String,
121 sub: Option<String>,
122 reason: Option<String>,
123}
124
125#[cfg(any(feature = "auth-import", test))]
127#[derive(Deserialize)]
128struct ImportResponse {
129 results: Vec<ResultRow>,
130}
131
132#[cfg(any(feature = "auth-import", test))]
135#[derive(Serialize)]
136struct ImportRequest<'a> {
137 dry_run: bool,
138 merge_by_email: bool,
139 users: &'a [Value],
140}
141
142#[derive(Debug)]
144struct Outcome {
145 dry_run: bool,
146 results: Vec<ResultRow>,
147}
148
149pub fn run(options: &ImportOptions) -> Result<(), String> {
158 let users = prepare(options)?;
159 let outcome = import_dispatch(options, &users)?;
160 finish(options, &outcome)
161}
162
163fn prepare(options: &ImportOptions) -> Result<Vec<Value>, String> {
166 if options.batch_size == 0 || options.batch_size > MAX_BATCH_SIZE {
167 return Err(format!(
168 "--batch-size must be between 1 and {MAX_BATCH_SIZE}, got {}",
169 options.batch_size
170 ));
171 }
172 load_users(&options.file)
173}
174
175fn load_users(path: &Path) -> Result<Vec<Value>, String> {
179 let text = std::fs::read_to_string(path)
180 .map_err(|err| format!("cannot read {}: {err}", path.display()))?;
181 let mut users = Vec::new();
182 let mut seen: HashSet<(String, String)> = HashSet::new();
183 for (index, raw) in text.lines().enumerate() {
184 let line = index + 1;
185 if raw.trim().is_empty() {
186 continue;
187 }
188 let value: Value =
189 serde_json::from_str(raw).map_err(|err| format!("line {line}: not JSON: {err}"))?;
190 let Some(object) = value.as_object() else {
191 return Err(format!("line {line}: not a JSON object"));
192 };
193 for field in ["external_provider", "external_id", "email"] {
194 match object.get(field) {
195 Some(Value::String(text)) if !text.is_empty() => {}
196 Some(Value::String(_)) => {
197 return Err(format!("line {line}: `{field}` must not be empty"));
198 }
199 Some(_) => return Err(format!("line {line}: `{field}` must be a string")),
200 None => return Err(format!("line {line}: missing required field `{field}`")),
201 }
202 }
203 match object.get("email_verified") {
204 Some(Value::Bool(_)) => {}
205 Some(_) => return Err(format!("line {line}: `email_verified` must be a boolean")),
206 None => {
207 return Err(format!(
208 "line {line}: missing required field `email_verified`"
209 ));
210 }
211 }
212 let provider = object["external_provider"].as_str().unwrap_or_default();
214 let id = object["external_id"].as_str().unwrap_or_default();
215 if !seen.insert((provider.to_owned(), id.to_owned())) {
216 return Err(format!(
217 "line {line}: duplicate (external_provider, external_id) — ({provider:?}, {id:?}) \
218 appears twice"
219 ));
220 }
221 users.push(value);
222 }
223 if users.is_empty() {
224 return Err(format!("{}: no users to import", path.display()));
225 }
226 Ok(users)
227}
228
229#[cfg(any(feature = "auth-import", test))]
232fn import_url(target: &str) -> String {
233 format!("{}{IMPORT_PATH}", target.trim_end_matches('/'))
234}
235
236#[cfg(any(feature = "auth-import", test))]
238async fn import_all(
239 http: &Arc<dyn HttpClient>,
240 options: &ImportOptions,
241 users: &[Value],
242) -> Result<Outcome, String> {
243 let url = import_url(&options.target);
244 let dry_run = options.dry_run();
245 let mut results = Vec::with_capacity(users.len());
246 for batch in users.chunks(options.batch_size) {
247 results.extend(send_batch(http, &url, options, dry_run, batch).await?);
248 }
249 Ok(Outcome { dry_run, results })
250}
251
252#[cfg(any(feature = "auth-import", test))]
255async fn send_batch(
256 http: &Arc<dyn HttpClient>,
257 url: &str,
258 options: &ImportOptions,
259 dry_run: bool,
260 users: &[Value],
261) -> Result<Vec<ResultRow>, String> {
262 let uri = url
263 .parse::<http::Uri>()
264 .map_err(|_| format!("`{}` is not a valid URL", options.target))?;
265 let body = serde_json::to_vec(&ImportRequest {
266 dry_run,
267 merge_by_email: options.merge_by_email,
268 users,
269 })
270 .map_err(|err| format!("cannot encode the import request: {err}"))?;
271 let authorization = http::HeaderValue::from_str(&format!("Bearer {}", options.admin_token))
274 .map_err(|_| {
275 "the environment variable `--admin-token-env` named does not hold a value usable as \
276 an `Authorization` header"
277 .to_owned()
278 })?;
279 let request = http::Request::builder()
280 .method(http::Method::POST)
281 .uri(uri)
282 .header(http::header::AUTHORIZATION, authorization)
283 .header(http::header::CONTENT_TYPE, "application/json")
284 .body(bytes::Bytes::from(body))
285 .map_err(|err| format!("cannot build the import request: {err}"))?;
286 let response = http
287 .send(request)
288 .await
289 .map_err(|err| format!("the import request to {url} failed: {err}"))?;
290 let status = response.status();
291 if !status.is_success() {
292 return Err(format!(
293 "the import request to {url} failed: HTTP {status}{}",
294 problem_detail(response.body())
295 ));
296 }
297 let parsed: ImportResponse = serde_json::from_slice(response.body())
298 .map_err(|err| format!("the import response was not the expected JSON: {err}"))?;
299 if parsed.results.len() != users.len() {
300 return Err(format!(
301 "the server returned {} results for {} users",
302 parsed.results.len(),
303 users.len()
304 ));
305 }
306 Ok(parsed.results)
307}
308
309#[cfg(any(feature = "auth-import", test))]
312fn problem_detail(body: &[u8]) -> String {
313 let Ok(value) = serde_json::from_slice::<Value>(body) else {
314 return String::new();
315 };
316 value
317 .get("detail")
318 .and_then(Value::as_str)
319 .or_else(|| value.get("title").and_then(Value::as_str))
320 .map(|detail| format!(" — {detail}"))
321 .unwrap_or_default()
322}
323
324fn finish(options: &ImportOptions, outcome: &Outcome) -> Result<(), String> {
326 let path = options.report_path();
327 write_report(&path, &outcome.results)?;
328 print_summary(outcome, &path);
329 Ok(())
330}
331
332fn write_report(path: &Path, results: &[ResultRow]) -> Result<(), String> {
334 let mut out = String::new();
335 for row in results {
336 let line = serde_json::to_string(row)
337 .map_err(|err| format!("cannot encode a report row: {err}"))?;
338 out.push_str(&line);
339 out.push('\n');
340 }
341 std::fs::write(path, out).map_err(|err| format!("cannot write {}: {err}", path.display()))
342}
343
344fn print_summary(outcome: &Outcome, path: &Path) {
348 if outcome.dry_run {
349 println!(
350 "fz auth import: dry run — the server validated {} user(s) and wrote nothing; pass \
351 --apply to import",
352 outcome.results.len()
353 );
354 } else {
355 println!("fz auth import: imported {} user(s)", outcome.results.len());
356 }
357 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
358 for row in &outcome.results {
359 *counts.entry(row.status.as_str()).or_default() += 1;
360 }
361 for status in STATUSES {
362 println!(" {status}: {}", counts.get(status).copied().unwrap_or(0));
363 }
364 println!("report: {}", path.display());
365}
366
367#[cfg(feature = "auth-import")]
370fn import_dispatch(options: &ImportOptions, users: &[Value]) -> Result<Outcome, String> {
371 let (http, _clock) = native_stack();
372 let runtime = tokio::runtime::Builder::new_current_thread()
373 .enable_all()
374 .build()
375 .map_err(|err| format!("cannot start the async runtime this import needs: {err}"))?;
376 runtime.block_on(import_all(&http, options, users))
377}
378
379#[cfg(feature = "auth-import")]
384fn native_stack() -> (Arc<dyn HttpClient>, Arc<dyn cratefield_core::Clock>) {
385 let clock: Arc<dyn cratefield_core::Clock> = Arc::new(cratefield_runtime_native::TokioClock);
386 let http: Arc<dyn HttpClient> = Arc::new(cratefield_core::BoundedHttpClient::new(
387 Arc::new(cratefield_runtime_native::ReqwestClient::new()),
388 Arc::clone(&clock),
389 ));
390 (http, clock)
391}
392
393#[cfg(not(feature = "auth-import"))]
399fn import_dispatch(_options: &ImportOptions, _users: &[Value]) -> Result<Outcome, String> {
400 Err(
401 "this `fz` was built without the `auth-import` feature, so it has no HTTP client and \
402 cannot import. Install one that has it — `cargo install cratefield-cli --features \
403 auth-import` — and run that binary (it needs no compiled-in harness); do not add the \
404 feature to the venture's own `cratefield-cli` dependency, which would pull the native \
405 runtime into its wasm build."
406 .to_owned(),
407 )
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use cratefield_core::HttpError;
414 use cratefield_testing::{FakeHttpClient, TempDir};
415 use http::Response;
416
417 fn json_response(body: &str) -> Result<Response<bytes::Bytes>, HttpError> {
418 Response::builder()
419 .status(200)
420 .body(bytes::Bytes::from(body.to_owned()))
421 .map_err(|err| HttpError::Transport(err.to_string()))
422 }
423
424 fn result_body(pairs: &[(&str, &str)]) -> String {
425 let results: Vec<Value> = pairs
426 .iter()
427 .map(|(provider, id)| {
428 serde_json::json!({
429 "external_provider": provider,
430 "external_id": id,
431 "status": "created",
432 "sub": format!("sub-{id}"),
433 "reason": Value::Null,
434 })
435 })
436 .collect();
437 serde_json::json!({ "dry_run": true, "results": results }).to_string()
438 }
439
440 fn options(dir: &TempDir, rows: &[(&str, &str)], batch_size: usize) -> ImportOptions {
441 let file = dir.join("users.jsonl");
442 let mut text = String::new();
443 for (provider, id) in rows {
444 use std::fmt::Write as _;
445 writeln!(
446 text,
447 "{{\"external_provider\":{provider:?},\"external_id\":{id:?},\
448 \"email\":\"u{id}@example.com\",\"email_verified\":true}}"
449 )
450 .expect("writes to a String");
451 }
452 std::fs::write(&file, text).expect("writes the fixture");
453 ImportOptions {
454 file,
455 target: "https://venture.example/".to_owned(),
456 admin_token: "test-admin-token-0123456789abcdef".to_owned(),
457 apply: false,
458 merge_by_email: false,
459 report: None,
460 batch_size,
461 }
462 }
463
464 #[cfg(test)]
468 fn import_with(http: &Arc<dyn HttpClient>, options: &ImportOptions) -> Result<Outcome, String> {
469 let users = prepare(options)?;
470 pollster::block_on(import_all(http, options, &users))
471 }
472
473 #[test]
474 fn batching_splits_into_the_requested_sizes() {
475 let dir = TempDir::new("fz-auth-import-batch");
476 let options = options(
477 &dir,
478 &[("p", "1"), ("p", "2"), ("p", "3"), ("p", "4"), ("p", "5")],
479 2,
480 );
481 let fake = FakeHttpClient::scripted(vec![
483 json_response(&result_body(&[("p", "1"), ("p", "2")])),
484 json_response(&result_body(&[("p", "3"), ("p", "4")])),
485 json_response(&result_body(&[("p", "5")])),
486 ]);
487 let http: Arc<dyn HttpClient> = Arc::new(fake.clone());
488
489 let outcome = import_with(&http, &options).expect("imports");
490
491 assert_eq!(outcome.results.len(), 5);
492 let captured = fake.captured();
493 assert_eq!(captured.len(), 3, "5 users at 2 per batch is 3 requests");
494 for (method, uri, _) in &captured {
495 assert_eq!(method, "POST");
496 assert_eq!(
497 uri,
498 "https://venture.example/v1/auth-core/admin/users/import"
499 );
500 }
501 let sizes: Vec<usize> = captured
502 .iter()
503 .map(|(_, _, body)| {
504 serde_json::from_str::<Value>(body).expect("request is JSON")["users"]
505 .as_array()
506 .expect("users array")
507 .len()
508 })
509 .collect();
510 assert_eq!(sizes, vec![2, 2, 1]);
511 }
512
513 #[test]
514 fn the_report_never_carries_an_email_or_a_password_hash() {
515 let dir = TempDir::new("fz-auth-import-report-privacy");
516 let email = "alice@example.com";
517 let hash = "argon2id$v=19$m=19456,t=2,p=1$c2FsdA$c2FsdA";
518 let file = dir.join("users.jsonl");
519 std::fs::write(
520 &file,
521 format!(
522 "{{\"external_provider\":\"legacy\",\"external_id\":\"42\",\"email\":\"{email}\",\
523 \"email_verified\":true,\"password_hash\":\"{hash}\"}}\n"
524 ),
525 )
526 .expect("writes the fixture");
527 let options = ImportOptions {
528 file,
529 target: "https://venture.example".to_owned(),
530 admin_token: "test-admin-token-0123456789abcdef".to_owned(),
531 apply: false,
532 merge_by_email: false,
533 report: None,
534 batch_size: DEFAULT_BATCH_SIZE,
535 };
536 let fake = FakeHttpClient::scripted(vec![json_response(&result_body(&[("legacy", "42")]))]);
537 let http: Arc<dyn HttpClient> = Arc::new(fake.clone());
538
539 let outcome = import_with(&http, &options).expect("imports");
540 let report = options.report_path();
541 write_report(&report, &outcome.results).expect("writes the report");
542
543 let bytes = std::fs::read_to_string(&report).expect("reads the report");
544 assert!(
545 !bytes.contains(email),
546 "the report carries no email: {bytes}"
547 );
548 assert!(!bytes.contains(hash), "the report carries no hash: {bytes}");
549 let (_, _, request_body) = fake.captured().into_iter().next().expect("one request");
552 assert!(
553 request_body.contains(email),
554 "the request carries the email"
555 );
556 assert!(request_body.contains(hash), "the request carries the hash");
557 }
558
559 #[test]
560 fn a_missing_field_aborts_locally_before_any_request() {
561 let dir = TempDir::new("fz-auth-import-missing-field");
562 let file = dir.join("users.jsonl");
563 std::fs::write(
564 &file,
565 "{\"external_provider\":\"p\",\"external_id\":\"1\",\"email_verified\":true}\n",
566 )
567 .expect("writes the fixture");
568 let options = ImportOptions {
569 file,
570 target: "https://venture.example".to_owned(),
571 admin_token: "test-admin-token-0123456789abcdef".to_owned(),
572 apply: false,
573 merge_by_email: false,
574 report: None,
575 batch_size: DEFAULT_BATCH_SIZE,
576 };
577 let fake = FakeHttpClient::ok_json("{}");
578 let http: Arc<dyn HttpClient> = Arc::new(fake.clone());
579
580 let error = import_with(&http, &options).expect_err("email is required");
581 assert!(error.contains("line 1"), "{error}");
582 assert!(error.contains("email"), "{error}");
583 assert!(fake.captured().is_empty(), "nothing was sent");
584 }
585
586 #[test]
587 fn a_duplicate_id_aborts_locally_before_any_request() {
588 let dir = TempDir::new("fz-auth-import-duplicate");
589 let options = options(&dir, &[("p", "7"), ("p", "7")], DEFAULT_BATCH_SIZE);
590 let fake = FakeHttpClient::ok_json("{}");
591 let http: Arc<dyn HttpClient> = Arc::new(fake.clone());
592
593 let error = import_with(&http, &options).expect_err("the id is duplicated");
594 assert!(error.contains("line 2"), "{error}");
595 assert!(error.contains("duplicate"), "{error}");
596 assert!(fake.captured().is_empty(), "nothing was sent");
597 }
598
599 #[test]
600 fn a_line_that_is_not_an_object_is_a_local_error_with_its_line_number() {
601 let dir = TempDir::new("fz-auth-import-not-object");
602 let file = dir.join("users.jsonl");
603 std::fs::write(&file, "\n[1, 2, 3]\n").expect("writes the fixture");
604 let error = load_users(&file).expect_err("an array is not a user");
605 assert!(error.contains("line 2"), "{error}");
606 assert!(error.contains("JSON object"), "{error}");
607 }
608
609 #[test]
610 fn dry_run_is_true_by_default_and_false_with_apply() {
611 let dir = TempDir::new("fz-auth-import-dry-run");
612 let dry = options(&dir, &[("p", "1")], DEFAULT_BATCH_SIZE);
613 let fake = FakeHttpClient::scripted(vec![json_response(&result_body(&[("p", "1")]))]);
614 let http: Arc<dyn HttpClient> = Arc::new(fake.clone());
615 assert!(dry.dry_run());
616 import_with(&http, &dry).expect("dry-runs");
617 let body = fake.captured().into_iter().next().expect("one request").2;
618 assert!(body.contains("\"dry_run\":true"), "{body}");
619
620 let mut apply = options(&dir, &[("p", "1")], DEFAULT_BATCH_SIZE);
621 apply.apply = true;
622 apply.merge_by_email = true;
623 let fake = FakeHttpClient::scripted(vec![json_response(&result_body(&[("p", "1")]))]);
624 let http: Arc<dyn HttpClient> = Arc::new(fake.clone());
625 assert!(!apply.dry_run());
626 import_with(&http, &apply).expect("applies");
627 let body = fake.captured().into_iter().next().expect("one request").2;
628 assert!(body.contains("\"dry_run\":false"), "{body}");
629 assert!(body.contains("\"merge_by_email\":true"), "{body}");
630 }
631
632 #[test]
633 fn an_out_of_range_batch_size_is_a_local_error() {
634 let dir = TempDir::new("fz-auth-import-batch-range");
635 for batch_size in [0, MAX_BATCH_SIZE + 1] {
636 let options = options(&dir, &[("p", "1")], batch_size);
637 let error = prepare(&options).expect_err("out of range");
638 assert!(error.contains("--batch-size"), "{error}");
639 }
640 }
641
642 #[test]
643 fn a_non_2xx_batch_fails_the_run_with_the_problem_detail() {
644 let dir = TempDir::new("fz-auth-import-non-2xx");
645 let options = options(&dir, &[("p", "1")], DEFAULT_BATCH_SIZE);
646 let fake = FakeHttpClient::scripted(vec![
647 Response::builder()
648 .status(400)
649 .body(bytes::Bytes::from(
650 r#"{"type":"about:blank","title":"Bad Request","detail":"too many users"}"#,
651 ))
652 .map_err(|err| HttpError::Transport(err.to_string())),
653 ]);
654 let http: Arc<dyn HttpClient> = Arc::new(fake.clone());
655
656 let error = import_with(&http, &options).expect_err("a 400 fails the run");
657 assert!(error.contains("400"), "{error}");
658 assert!(error.contains("too many users"), "{error}");
659 }
660
661 #[test]
662 #[cfg(not(feature = "auth-import"))]
663 fn the_refusal_points_at_an_installed_binary() {
664 let dir = TempDir::new("fz-auth-import-refusal");
665 let options = options(&dir, &[("p", "1")], DEFAULT_BATCH_SIZE);
666 let error = run(&options).expect_err("no client, no import");
667 assert!(
668 error.contains("cargo install cratefield-cli --features auth-import"),
669 "{error}"
670 );
671 assert!(!error.contains("cratefield-cli = {"), "{error}");
672 }
673}