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