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// `pub(crate)`: `fz import supabase users` sends the same contract
117// through `send_batch` and reports these rows (issue #659).
118#[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/// The server's reply to one batch.
128#[cfg(any(feature = "auth-import", test))]
129#[derive(Deserialize)]
130struct ImportResponse {
131    results: Vec<ResultRow>,
132}
133
134/// The request body for one batch. The user objects are passed through
135/// verbatim, unknown fields included.
136#[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/// What a completed run reports.
145#[derive(Debug)]
146struct Outcome {
147    dry_run: bool,
148    results: Vec<ResultRow>,
149}
150
151/// `fz auth import`: parse, validate, batch, send, report.
152///
153/// # Errors
154///
155/// A local defect in the file (a line that is not a JSON object, a missing
156/// required field, a duplicate `(external_provider, external_id)`, an
157/// out-of-range `--batch-size`), any batch that cannot be sent, any
158/// non-2xx reply, and a report that cannot be written.
159pub fn run(options: &ImportOptions) -> Result<(), String> {
160    let users = prepare(options)?;
161    let outcome = import_dispatch(options, &users)?;
162    finish(options, &outcome)
163}
164
165/// Reads the file and refuses everything a local check can catch, before
166/// a single byte goes to the network.
167fn 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
177/// Reads one JSON object per line, skipping blank lines, and validates
178/// exactly what the server requires of each. The objects are returned
179/// unchanged.
180fn 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        // Validated above, so the casts cannot fail.
215        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/// The URL a batch is sent to: the base URL with any trailing slash
232/// removed, then [`IMPORT_PATH`].
233#[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/// Sends every batch in order, stopping at the first failure.
239#[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/// Sends one batch and returns its results. The server answers one result
265/// per user in request order.
266///
267/// The shared seam: `fz auth import` passes an [`ImportOptions`]'s pieces,
268/// and `fz import supabase users` (issue #659) passes its own, so the one
269/// HTTP path — headers, problem detail, result-count check — is written
270/// once.
271#[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    // The header value is built without echoing either the token or the
290    // builder's error: a malformed token must not reach a terminal.
291    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/// The `detail` (or `title`) of an RFC 9457 problem body, as a suffix for
328/// an error line. Empty when the body is not problem JSON.
329#[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
342/// Writes the report and prints the summary.
343fn 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
350/// Writes one result per line, exactly the five result fields.
351fn 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
362/// Prints the dry-run line, one line per status (zeros included, so a
363/// status that never appeared is visibly a zero rather than absent), and
364/// the report path.
365fn 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/// Sends the batches through the native runtime's HTTP client, or refuses
386/// when this `fz` was built without one.
387#[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/// The native runtime's own client — reqwest behind the outbound policy,
398/// wrapped in the port's bounds — the same construction `fz push send`
399/// uses (issue #184), so the import goes through the client the runtime
400/// uses rather than one written for the occasion.
401#[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/// The refusal an `fz` without the feature prints: one line saying exactly
412/// what to rebuild. Mirrors `push::send_now`'s, and for the same reason —
413/// `auth-import` pulls `cratefield-runtime-native`, which `compile_error!`s
414/// on wasm32, so the feature belongs on a separately installed binary and
415/// never on a venture's own `cratefield-cli` dependency.
416#[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    /// The only synchronous orchestrator tests drive: it runs the same
483    /// `prepare` then `import_all` the command does, over `pollster`
484    /// rather than a tokio runtime so a `FakeHttpClient` needs no reactor.
485    #[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        // 5 users at 2 per batch is 2 + 2 + 1.
500        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        // The request does carry them — that is the point of the import —
568        // but it reaches the server and nowhere this command prints.
569        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}