Skip to main content

cratefield_cli/
auth_import.rs

1//! `fz auth import` (issue #650): loads a JSONL of users into a running
2//! venture's `auth-core` module over its admin API.
3//!
4//! The migration path this serves is the one every venture eventually
5//! walks: a system of record already holds the users, and moving them in
6//! must not lose the password verifier or the verified email. The server
7//! half — `POST <target>/v1/auth-core/admin/users/import`, behind
8//! `Authorization: Bearer <ADMIN_TOKEN>` — validates every user and
9//! reports one result per user; this command reads the file, aborts on a
10//! local defect **before sending anything**, batches what is left, and
11//! writes a report.
12//!
13//! # Dry run by default
14//!
15//! Without `--apply` the request carries `"dry_run": true`, so the server
16//! validates and reports without writing. `--apply` is the one thing that
17//! writes.
18//!
19//! # The report carries no user data
20//!
21//! The report JSONL holds exactly the server's per-user verdict —
22//! `external_provider`, `external_id`, `status`, `sub`, `reason` — and
23//! never an email or a password hash. The file this command reads is the
24//! only place those appear, and it is never echoed: the summary counts
25//! statuses, and the admin token is read from the environment and never
26//! printed.
27//!
28//! # Exit code
29//!
30//! Non-zero when a batch cannot be sent or the server answers a non-2xx
31//! (the run stops at the first such batch, so the report describes a
32//! prefix only). A run that completes printing `conflict` or `invalid`
33//! results still exits zero: those are the server having *answered*, and
34//! a conflict is a fact to act on, not a transport failure to retry. The
35//! counts are printed either way.
36
37use std::collections::{BTreeMap, HashSet};
38use std::path::{Path, PathBuf};
39
40// The request path exists only where it can run — behind `auth-import`, or
41// in the tests that drive it through a fake. Without the feature the
42// command refuses before reaching any of it, and an unused copy would fail
43// CI's `-D warnings`.
44#[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
51/// The `auth-core` admin route, relative to the venture's base URL.
52///
53/// `auth-core` is mounted at `/v1/<name>` (`crates/core/src/module.rs:2`,
54/// `crates/core/src/harness.rs:367`), and its admin router lives under
55/// `/admin` (`crates/auth-core/src/clients.rs:53`) behind the harness
56/// `require_admin` layer (`crates/core/src/admin.rs`), which reads
57/// `Authorization: Bearer <ADMIN_TOKEN>`.
58pub const IMPORT_PATH: &str = "/v1/auth-core/admin/users/import";
59
60/// Users per request, when `--batch-size` is not given.
61pub const DEFAULT_BATCH_SIZE: usize = 500;
62
63/// The server refuses a request carrying more than this, so a larger
64/// `--batch-size` is a local error rather than an HTTP 4xx.
65pub const MAX_BATCH_SIZE: usize = 1000;
66
67/// Every status the server reports, in the order the summary prints them.
68const STATUSES: [&str; 5] = ["created", "unchanged", "merged", "conflict", "invalid"];
69
70/// Everything `run` needs, assembled by the dispatcher so the token is
71/// read from the environment exactly once and travels no further than the
72/// request header.
73pub struct ImportOptions {
74    /// The JSONL file of user objects.
75    pub file: PathBuf,
76    /// The venture's base URL; `IMPORT_PATH` is appended.
77    pub target: String,
78    /// The `ADMIN_TOKEN` value, read from the variable `--admin-token-env`
79    /// names. Never printed.
80    pub admin_token: String,
81    /// `--apply`: write. Absent, the run is a dry run.
82    pub apply: bool,
83    /// `--merge-by-email`: let the server merge into an existing account
84    /// sharing the email.
85    pub merge_by_email: bool,
86    /// `--report`: where the report JSONL goes. Defaults to
87    /// `<file>.report.jsonl`.
88    pub report: Option<PathBuf>,
89    /// Users per request.
90    pub batch_size: usize,
91}
92
93impl ImportOptions {
94    /// Whether this run asks the server to change nothing. `--apply` is
95    /// the only way to make it false.
96    #[must_use]
97    pub fn dry_run(&self) -> bool {
98        !self.apply
99    }
100
101    /// Where the report is written: `--report`, or `<file>.report.jsonl`.
102    #[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/// One user's verdict, as the server reports it. The field set is the
114/// report's whole contract, and it is deliberately narrower than the
115/// request: no email, no password hash.
116#[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/// The server's reply to one batch.
126#[cfg(any(feature = "auth-import", test))]
127#[derive(Deserialize)]
128struct ImportResponse {
129    results: Vec<ResultRow>,
130}
131
132/// The request body for one batch. The user objects are passed through
133/// verbatim, unknown fields included.
134#[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/// What a completed run reports.
143#[derive(Debug)]
144struct Outcome {
145    dry_run: bool,
146    results: Vec<ResultRow>,
147}
148
149/// `fz auth import`: parse, validate, batch, send, report.
150///
151/// # Errors
152///
153/// A local defect in the file (a line that is not a JSON object, a missing
154/// required field, a duplicate `(external_provider, external_id)`, an
155/// out-of-range `--batch-size`), any batch that cannot be sent, any
156/// non-2xx reply, and a report that cannot be written.
157pub fn run(options: &ImportOptions) -> Result<(), String> {
158    let users = prepare(options)?;
159    let outcome = import_dispatch(options, &users)?;
160    finish(options, &outcome)
161}
162
163/// Reads the file and refuses everything a local check can catch, before
164/// a single byte goes to the network.
165fn 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
175/// Reads one JSON object per line, skipping blank lines, and validates
176/// exactly what the server requires of each. The objects are returned
177/// unchanged.
178fn 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        // Validated above, so the casts cannot fail.
213        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/// The URL a batch is sent to: the base URL with any trailing slash
230/// removed, then [`IMPORT_PATH`].
231#[cfg(any(feature = "auth-import", test))]
232fn import_url(target: &str) -> String {
233    format!("{}{IMPORT_PATH}", target.trim_end_matches('/'))
234}
235
236/// Sends every batch in order, stopping at the first failure.
237#[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/// Sends one batch and returns its results. The server answers one result
253/// per user in request order.
254#[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    // The header value is built without echoing either the token or the
272    // builder's error: a malformed token must not reach a terminal.
273    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/// The `detail` (or `title`) of an RFC 9457 problem body, as a suffix for
310/// an error line. Empty when the body is not problem JSON.
311#[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
324/// Writes the report and prints the summary.
325fn 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
332/// Writes one result per line, exactly the five result fields.
333fn 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
344/// Prints the dry-run line, one line per status (zeros included, so a
345/// status that never appeared is visibly a zero rather than absent), and
346/// the report path.
347fn 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/// Sends the batches through the native runtime's HTTP client, or refuses
368/// when this `fz` was built without one.
369#[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/// The native runtime's own client — reqwest behind the outbound policy,
380/// wrapped in the port's bounds — the same construction `fz push send`
381/// uses (issue #184), so the import goes through the client the runtime
382/// uses rather than one written for the occasion.
383#[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/// The refusal an `fz` without the feature prints: one line saying exactly
394/// what to rebuild. Mirrors `push::send_now`'s, and for the same reason —
395/// `auth-import` pulls `cratefield-runtime-native`, which `compile_error!`s
396/// on wasm32, so the feature belongs on a separately installed binary and
397/// never on a venture's own `cratefield-cli` dependency.
398#[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    /// The only synchronous orchestrator tests drive: it runs the same
465    /// `prepare` then `import_all` the command does, over `pollster`
466    /// rather than a tokio runtime so a `FakeHttpClient` needs no reactor.
467    #[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        // 5 users at 2 per batch is 2 + 2 + 1.
482        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        // The request does carry them — that is the point of the import —
550        // but it reaches the server and nowhere this command prints.
551        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}