diff --git a/rust/src/auth_login/args.rs b/rust/src/auth_login/args.rs new file mode 100644 index 00000000..3253e3cb --- /dev/null +++ b/rust/src/auth_login/args.rs @@ -0,0 +1,204 @@ +use cli_engine::Cli; + +pub(crate) struct ParsedAuthLoginArgs { + pub(crate) accept_agreements: bool, + pub(crate) output_format: String, + pub(crate) engine_args: Vec, +} + +pub(crate) fn parse( + cli: &Cli, + args: &[String], + default_format: &str, +) -> Option { + let path_args = args + .iter() + .filter(|arg| arg.as_str() != "--accept-agreements") + .cloned() + .collect::>(); + let bool_flags = cli_engine::derive_bool_flags(cli.root_command()); + let value_flags = cli_engine::derive_value_flags(cli.root_command()); + let path = cli_engine::extract_command_path(&path_args, &bool_flags, &value_flags); + + if path != "auth:login" + || args + .iter() + .any(|arg| matches!(arg.as_str(), "--help" | "-h" | "--version" | "-V")) + { + return None; + } + + let output_format = cli_engine::extract_output_format(args, default_format); + let accept_agreements = args.iter().any(|arg| arg == "--accept-agreements"); + let mut engine_args = Vec::with_capacity(args.len() + 2); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--accept-agreements" | "--json" | "--toon" | "--human" => { + index += 1; + } + "--output" | "-o" => { + index += 1; + if index < args.len() { + index += 1; + } + } + arg if arg.starts_with("--output=") => { + index += 1; + } + _ => { + engine_args.push(args[index].clone()); + index += 1; + } + } + } + + engine_args.extend(["--output".to_owned(), "json".to_owned()]); + + Some(ParsedAuthLoginArgs { + accept_agreements, + output_format, + engine_args, + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use cli_engine::{Cli, CliConfig}; + + use super::parse; + use crate::auth::GoDaddyAuthProvider; + + fn argv(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + fn test_cli() -> Cli { + Cli::new( + CliConfig::new("gddy", "test", "gddy") + .with_default_auth_provider("godaddy") + .with_auth_provider(Arc::new(GoDaddyAuthProvider::new())) + .with_register_flags(Arc::new(|command| { + command.arg( + clap::Arg::new("env") + .long("env") + .global(true) + .value_name("ENV"), + ) + })), + ) + } + + #[test] + fn detects_auth_login_with_global_flags_anywhere() { + let cli = test_cli(); + assert!( + parse( + &cli, + &argv(&["gddy", "--env", "ote", "auth", "login", "--human"]), + "json" + ) + .is_some() + ); + assert!( + parse( + &cli, + &argv(&["gddy", "auth", "--env", "ote", "login", "--output", "toon"]), + "json" + ) + .is_some() + ); + } + + #[test] + fn strips_acceptance_and_forces_internal_json() { + let parsed = parse( + &test_cli(), + &argv(&[ + "gddy", + "auth", + "login", + "--accept-agreements", + "--human", + "--scope", + "domains:read", + ]), + "json", + ) + .expect("auth login"); + + assert!(parsed.accept_agreements); + assert_eq!(parsed.output_format, "human"); + assert!( + !parsed + .engine_args + .iter() + .any(|arg| { matches!(arg.as_str(), "--accept-agreements" | "--human") }) + ); + assert!( + parsed + .engine_args + .ends_with(&["--output".to_owned(), "json".to_owned()]) + ); + assert!( + parsed + .engine_args + .windows(2) + .any(|pair| { pair == ["--scope".to_owned(), "domains:read".to_owned()] }) + ); + } + + #[test] + fn preserves_selected_output_format_and_repeated_scopes() { + let parsed = parse( + &test_cli(), + &argv(&[ + "gddy", + "auth", + "login", + "--output=toon", + "--scope", + "domains:read", + "--scope", + "domains:write", + ]), + "human", + ) + .expect("auth login"); + + assert_eq!(parsed.output_format, "toon"); + assert_eq!( + parsed + .engine_args + .windows(2) + .filter(|pair| pair[0] == "--scope") + .count(), + 2 + ); + assert!(!parsed.engine_args.iter().any(|arg| arg == "--output=toon")); + assert!( + parsed + .engine_args + .ends_with(&["--output".to_owned(), "json".to_owned()]) + ); + } + + #[test] + fn bypasses_status_help_version_and_other_commands() { + let cli = test_cli(); + for values in [ + &["gddy", "auth", "status"][..], + &["gddy", "auth", "login", "--help"][..], + &["gddy", "auth", "login", "-h"][..], + &["gddy", "--version"][..], + &["gddy", "-V"][..], + &["gddy", "domain", "list"][..], + &["gddy", "domain", "list", "--accept-agreements"][..], + ] { + assert!(parse(&cli, &argv(values), "json").is_none(), "{values:?}"); + } + } +} diff --git a/rust/src/auth_login/mod.rs b/rust/src/auth_login/mod.rs new file mode 100644 index 00000000..b8f70390 --- /dev/null +++ b/rust/src/auth_login/mod.rs @@ -0,0 +1,537 @@ +pub(crate) mod args; +mod output; + +use std::io::{self, BufRead, IsTerminal, Write}; +use std::process::ExitCode; + +use cli_engine::auth::AuthProvider; +use cli_engine::{Cli, CliRunOutput, Envelope}; + +use crate::auth::GoDaddyAuthProvider; +use crate::environments; +use crate::onboarding::{AgreementDecision, OnboardingClient, decide, prompt_accept_agreements}; + +pub(crate) enum FinishLogin { + Success(Envelope), + AgreementsRequired(Envelope), +} + +pub(crate) async fn finish_login( + mut envelope: Envelope, + token: &str, + base_url: &str, + accept_agreements: bool, + is_tty: bool, + reader: &mut impl BufRead, + stderr: &mut impl Write, +) -> FinishLogin { + let client = OnboardingClient::new(base_url); + let status = match client.status(token).await { + Ok(status) => status, + Err(_) => { + output::apply_failed(&mut envelope); + return FinishLogin::Success(envelope); + } + }; + + let prompt_accepted = if status.status == "PENDING" && is_tty { + Some(prompt_accept_agreements(reader, stderr).unwrap_or(false)) + } else { + None + }; + + match decide(&status, is_tty, accept_agreements, prompt_accepted) { + AgreementDecision::AlreadyComplete { org_id } => { + output::apply_complete(&mut envelope, org_id); + FinishLogin::Success(envelope) + } + AgreementDecision::CompletePending => match client.complete(token).await { + Ok(result) => { + output::apply_complete(&mut envelope, result.organization_id); + FinishLogin::Success(envelope) + } + Err(_) => { + output::apply_failed(&mut envelope); + FinishLogin::Success(envelope) + } + }, + AgreementDecision::AgreementsRequired => { + FinishLogin::AgreementsRequired(output::agreements_required_envelope()) + } + AgreementDecision::UnsupportedStatus => { + output::apply_failed(&mut envelope); + FinishLogin::Success(envelope) + } + } +} + +pub async fn run(cli: &Cli, auth_provider: &GoDaddyAuthProvider, args: Vec) -> ExitCode { + let parsed = args::parse( + cli, + &args, + &cli_engine::default_output_format(environments::APP_ID), + ) + .expect("dispatch only calls run for auth login"); + + let login = cli.run(parsed.engine_args.clone()).await; + let mut envelope: Envelope = match serde_json::from_str(&login.rendered) { + Ok(envelope) => envelope, + Err(_) => return write_fallback(&login), + }; + + if login.exit_code != 0 || envelope.error.is_some() { + return write_envelope(&parsed.output_format, &envelope, login.exit_code); + } + + let env = envelope + .data + .as_ref() + .and_then(|data| data.get("env")) + .and_then(|value| value.as_str()) + .unwrap_or(environments::DEFAULT_ENV) + .to_owned(); + + let credential = match auth_provider.status(&env).await { + Ok(credential) => credential, + Err(_) => { + output::apply_failed(&mut envelope); + return write_envelope(&parsed.output_format, &envelope, 0); + } + }; + + let Some(base_url) = environments::devx_core_url(&env) else { + output::apply_failed(&mut envelope); + return write_envelope(&parsed.output_format, &envelope, 0); + }; + + let is_tty = io::stdin().is_terminal(); + let mut stdin = io::stdin().lock(); + let mut stderr = io::stderr(); + + match finish_login( + envelope, + &credential.token, + &base_url, + parsed.accept_agreements, + is_tty, + &mut stdin, + &mut stderr, + ) + .await + { + FinishLogin::Success(envelope) => write_envelope(&parsed.output_format, &envelope, 0), + FinishLogin::AgreementsRequired(envelope) => { + write_envelope(&parsed.output_format, &envelope, 1) + } + } +} + +fn write_envelope(format: &str, envelope: &Envelope, exit_code: i32) -> ExitCode { + match output::render(format, envelope) { + Ok(rendered) => write_rendered(&rendered, exit_code, &mut io::stdout(), &mut io::stderr()), + Err(error) => { + let _ = writeln!(io::stderr(), "{error}"); + ExitCode::from(1) + } + } +} + +fn write_fallback(login: &CliRunOutput) -> ExitCode { + write_rendered( + &login.rendered, + login.exit_code, + &mut io::stdout(), + &mut io::stderr(), + ) +} + +fn write_rendered( + rendered: &str, + exit_code: i32, + stdout: &mut impl Write, + stderr: &mut impl Write, +) -> ExitCode { + let write_result = if exit_code == 0 { + stdout.write_all(rendered.as_bytes()) + } else { + stderr.write_all(rendered.as_bytes()) + }; + if write_result.is_err() { + return ExitCode::from(1); + } + process_exit_code(exit_code) +} + +fn process_exit_code(code: i32) -> ExitCode { + if code == 0 { + return ExitCode::SUCCESS; + } + match u8::try_from(code) { + Ok(code) if code != 0 => ExitCode::from(code), + Ok(_) | Err(_) => ExitCode::from(1), + } +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use cli_engine::{CliRunOutput, Envelope, NextAction}; + use httpmock::{Method::POST, MockServer}; + use serde_json::json; + + use super::{FinishLogin, finish_login, write_rendered}; + use crate::auth_login::output; + + fn login_envelope() -> Envelope { + Envelope::success( + json!({ + "provider": "godaddy", + "env": "ote", + "identity": "customer", + "expires_at": "2026-07-20T00:00:00Z", + "scopes": [] + }), + "auth", + ) + .with_next_actions(vec![NextAction::new("gddy auth status", "Check login")]) + } + + fn assert_complete(envelope: &Envelope, org_id: &str) { + let data = envelope.data.as_ref().expect("data"); + assert_eq!(data["org_id"], org_id); + assert_eq!(data["onboarding"], "complete"); + assert_eq!(data["provider"], "godaddy"); + assert!(envelope.warnings.is_empty()); + } + + fn assert_failed(envelope: &Envelope) { + let data = envelope.data.as_ref().expect("data"); + assert_eq!(data["onboarding"], "failed"); + assert_eq!( + envelope.warnings, + vec![output::ONBOARDING_WARNING.to_owned()] + ); + } + + #[tokio::test] + async fn active_status_completes_without_prompt_or_cli_call() { + let server = MockServer::start_async().await; + let status = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-active", "status": "ACTIVE" } + })); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-active", "status": "ACTIVE" } + })); + }) + .await; + + let mut input = Cursor::new(b""); + let mut stderr = Vec::new(); + let result = finish_login( + login_envelope(), + "test-token", + &server.base_url(), + false, + true, + &mut input, + &mut stderr, + ) + .await; + + match result { + FinishLogin::Success(envelope) => assert_complete(&envelope, "org-active"), + FinishLogin::AgreementsRequired(_) => panic!("expected success"), + } + assert!(stderr.is_empty()); + status.assert_async().await; + assert_eq!(complete.hits_async().await, 0); + } + + #[tokio::test] + async fn pending_tty_enter_prompts_and_completes() { + let server = MockServer::start_async().await; + let status = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-pending", "status": "PENDING" } + })); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-pending", "status": "ACTIVE" } + })); + }) + .await; + + let mut input = Cursor::new(b"\n"); + let mut stderr = Vec::new(); + let result = finish_login( + login_envelope(), + "test-token", + &server.base_url(), + false, + true, + &mut input, + &mut stderr, + ) + .await; + + match result { + FinishLogin::Success(envelope) => assert_complete(&envelope, "org-pending"), + FinishLogin::AgreementsRequired(_) => panic!("expected success"), + } + let prompt = String::from_utf8(stderr).expect("utf8"); + assert!(prompt.contains("terms-of-use")); + assert!(prompt.contains("privacy-policy")); + assert!(prompt.contains("developer-agreement")); + status.assert_async().await; + complete.assert_async().await; + } + + #[tokio::test] + async fn pending_non_tty_flag_completes_without_prompt() { + let server = MockServer::start_async().await; + let status = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-pending", "status": "PENDING" } + })); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-pending", "status": "ACTIVE" } + })); + }) + .await; + + let mut input = Cursor::new(b""); + let mut stderr = Vec::new(); + let result = finish_login( + login_envelope(), + "test-token", + &server.base_url(), + true, + false, + &mut input, + &mut stderr, + ) + .await; + + match result { + FinishLogin::Success(envelope) => assert_complete(&envelope, "org-pending"), + FinishLogin::AgreementsRequired(_) => panic!("expected success"), + } + assert!(stderr.is_empty()); + status.assert_async().await; + complete.assert_async().await; + } + + #[tokio::test] + async fn pending_non_tty_without_flag_requires_agreements() { + let server = MockServer::start_async().await; + let status = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-pending", "status": "PENDING" } + })); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-pending", "status": "ACTIVE" } + })); + }) + .await; + + let mut input = Cursor::new(b""); + let mut stderr = Vec::new(); + let result = finish_login( + login_envelope(), + "test-token", + &server.base_url(), + false, + false, + &mut input, + &mut stderr, + ) + .await; + + match result { + FinishLogin::AgreementsRequired(envelope) => { + let error = envelope.error.expect("structured error"); + assert_eq!(error.code, "AGREEMENTS_REQUIRED"); + } + FinishLogin::Success(_) => panic!("expected agreements required"), + } + assert!(stderr.is_empty()); + status.assert_async().await; + assert_eq!(complete.hits_async().await, 0); + } + + #[tokio::test] + async fn status_http_failure_is_non_fatal() { + let server = MockServer::start_async().await; + let status = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(500).body("secret backend detail"); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/cli"); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-1", "status": "ACTIVE" } + })); + }) + .await; + + let mut input = Cursor::new(b""); + let mut stderr = Vec::new(); + let result = finish_login( + login_envelope(), + "test-token", + &server.base_url(), + true, + false, + &mut input, + &mut stderr, + ) + .await; + + match result { + FinishLogin::Success(envelope) => { + assert_failed(&envelope); + let rendered = serde_json::to_string(&envelope).expect("serialize"); + assert!(!rendered.contains("secret backend detail")); + assert!(!rendered.contains("test-token")); + } + FinishLogin::AgreementsRequired(_) => panic!("expected non-fatal success"), + } + status.assert_async().await; + assert_eq!(complete.hits_async().await, 0); + } + + #[tokio::test] + async fn completion_http_failure_is_non_fatal() { + let server = MockServer::start_async().await; + let status = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-pending", "status": "PENDING" } + })); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/cli"); + then.status(500).body("secret backend detail"); + }) + .await; + + let mut input = Cursor::new(b""); + let mut stderr = Vec::new(); + let result = finish_login( + login_envelope(), + "test-token", + &server.base_url(), + true, + false, + &mut input, + &mut stderr, + ) + .await; + + match result { + FinishLogin::Success(envelope) => { + assert_failed(&envelope); + let rendered = serde_json::to_string(&envelope).expect("serialize"); + assert!(!rendered.contains("secret backend detail")); + assert!(!rendered.contains("test-token")); + } + FinishLogin::AgreementsRequired(_) => panic!("expected non-fatal success"), + } + status.assert_async().await; + complete.assert_async().await; + } + + #[test] + fn write_rendered_routes_success_and_error_channels() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let success = write_rendered("ok\n", 0, &mut stdout, &mut stderr); + assert_eq!(success, std::process::ExitCode::SUCCESS); + assert_eq!(stdout, b"ok\n"); + assert!(stderr.is_empty()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let failure = write_rendered("err\n", 1, &mut stdout, &mut stderr); + assert_eq!(failure, std::process::ExitCode::from(1)); + assert!(stdout.is_empty()); + assert_eq!(stderr, b"err\n"); + } + + #[test] + fn oauth_error_envelope_keeps_requested_format_and_exit_code() { + let envelope = output::agreements_required_envelope(); + let rendered = output::render("json", &envelope).expect("render"); + assert!(rendered.contains("AGREEMENTS_REQUIRED")); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + write_rendered(&rendered, 1, &mut stdout, &mut stderr), + std::process::ExitCode::from(1) + ); + assert!(stdout.is_empty()); + assert_eq!(stderr, rendered.as_bytes()); + } + + #[test] + fn malformed_internal_json_falls_back_to_original_payload() { + let login = CliRunOutput { + exit_code: 2, + rendered: "not-json".to_owned(), + }; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + write_rendered(&login.rendered, login.exit_code, &mut stdout, &mut stderr), + std::process::ExitCode::from(2) + ); + assert!(stdout.is_empty()); + assert_eq!(stderr, b"not-json"); + } +} diff --git a/rust/src/auth_login/output.rs b/rust/src/auth_login/output.rs new file mode 100644 index 00000000..a856325b --- /dev/null +++ b/rust/src/auth_login/output.rs @@ -0,0 +1,204 @@ +use std::borrow::Cow; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use cli_engine::{DetailedError, Envelope, build_detailed_error_envelope, render_format}; +use serde_json::{Map, Value}; + +use crate::next_action::next_action; + +pub(crate) const ONBOARDING_WARNING: &str = + "Authentication succeeded, but onboarding could not be checked."; + +#[derive(Debug)] +pub(crate) struct AgreementsRequiredError; + +impl Display for AgreementsRequiredError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str("GoDaddy Developer agreements must be accepted to finish onboarding.") + } +} + +impl Error for AgreementsRequiredError {} + +impl DetailedError for AgreementsRequiredError { + fn error_code(&self) -> Cow<'static, str> { + Cow::Borrowed("AGREEMENTS_REQUIRED") + } + + fn error_system(&self) -> Option> { + Some(Cow::Borrowed("auth")) + } + + fn error_request_id(&self) -> Option> { + None + } +} + +pub(crate) fn apply_complete(envelope: &mut Envelope, org_id: impl Into) { + let data = object_data(envelope); + data.insert("org_id".to_owned(), Value::String(org_id.into())); + data.insert( + "onboarding".to_owned(), + Value::String("complete".to_owned()), + ); +} + +pub(crate) fn apply_failed(envelope: &mut Envelope) { + object_data(envelope).insert("onboarding".to_owned(), Value::String("failed".to_owned())); + if !envelope + .warnings + .iter() + .any(|warning| warning == ONBOARDING_WARNING) + { + envelope.warnings.push(ONBOARDING_WARNING.to_owned()); + } +} + +pub(crate) fn agreements_required_envelope() -> Envelope { + build_detailed_error_envelope(&AgreementsRequiredError, "auth").with_next_actions(vec![ + next_action( + "auth login --accept-agreements", + "Accept GoDaddy Developer agreements non-interactively", + ), + ]) +} + +pub(crate) fn render(format: &str, envelope: &Envelope) -> cli_engine::Result { + render_format(format, envelope) +} + +fn object_data(envelope: &mut Envelope) -> &mut Map { + if envelope + .data + .as_mut() + .and_then(Value::as_object_mut) + .is_none() + { + envelope.data = Some(Value::Object(Map::new())); + } + + envelope + .data + .as_mut() + .and_then(Value::as_object_mut) + .expect("envelope data was replaced with an object") +} + +#[cfg(test)] +mod tests { + use cli_engine::{Envelope, NextAction}; + use serde_json::json; + + fn login_envelope() -> Envelope { + Envelope::success( + json!({ + "provider": "godaddy", + "env": "ote", + "identity": "customer", + "expires_at": "2026-07-20T00:00:00Z", + "scopes": [] + }), + "auth", + ) + .with_next_actions(vec![NextAction::new("gddy auth status", "Check login")]) + } + + #[test] + fn completion_preserves_login_data_and_envelope_context() { + let mut envelope = login_envelope(); + let metadata = envelope.metadata.clone(); + let next_actions = envelope.next_actions.clone(); + + super::apply_complete(&mut envelope, "org-123"); + + let data = envelope.data.expect("completion data"); + assert_eq!(data["provider"], "godaddy"); + assert_eq!(data["env"], "ote"); + assert_eq!(data["identity"], "customer"); + assert_eq!(data["expires_at"], "2026-07-20T00:00:00Z"); + assert_eq!(data["scopes"], json!([])); + assert_eq!(data["org_id"], "org-123"); + assert_eq!(data["onboarding"], "complete"); + assert_eq!(envelope.metadata, metadata); + assert_eq!(envelope.next_actions, next_actions); + } + + #[test] + fn completion_replaces_non_object_data_with_onboarding_data() { + let mut envelope = Envelope::success("unexpected", "auth"); + + super::apply_complete(&mut envelope, "org-123"); + + assert_eq!( + envelope.data, + Some(json!({ + "org_id": "org-123", + "onboarding": "complete" + })) + ); + } + + #[test] + fn failure_marks_onboarding_and_adds_stable_warning() { + let mut envelope = login_envelope(); + + super::apply_failed(&mut envelope); + + assert_eq!( + envelope.data.as_ref().expect("failure data")["onboarding"], + "failed" + ); + assert_eq!( + envelope.warnings, + vec![super::ONBOARDING_WARNING.to_owned()] + ); + } + + #[test] + fn repeated_failure_does_not_duplicate_warning() { + let mut envelope = login_envelope(); + + super::apply_failed(&mut envelope); + super::apply_failed(&mut envelope); + + assert_eq!( + envelope + .warnings + .iter() + .filter(|warning| warning.as_str() == super::ONBOARDING_WARNING) + .count(), + 1 + ); + } + + #[test] + fn agreements_required_is_a_structured_auth_error_with_next_action() { + let envelope = super::agreements_required_envelope(); + let error = envelope.error.expect("structured error"); + + assert_eq!(error.code, "AGREEMENTS_REQUIRED"); + assert_eq!(error.system, "auth"); + assert_eq!(envelope.next_actions.len(), 1); + assert_eq!( + envelope.next_actions[0].command, + "gddy auth login --accept-agreements" + ); + assert_eq!( + envelope.next_actions[0].description, + "Accept GoDaddy Developer agreements non-interactively" + ); + } + + #[test] + fn native_envelope_renders_in_all_supported_formats() -> cli_engine::Result<()> { + let envelope = login_envelope(); + + for format in ["json", "human", "toon"] { + let rendered = super::render(format, &envelope)?; + assert!(!rendered.is_empty(), "{format} rendering was empty"); + } + + Ok(()) + } +} diff --git a/rust/src/environments/mod.rs b/rust/src/environments/mod.rs index 0793ed85..6db87beb 100644 --- a/rust/src/environments/mod.rs +++ b/rust/src/environments/mod.rs @@ -47,6 +47,7 @@ struct Builtin { name: &'static str, api_url: &'static str, client_id: &'static str, + devx_core_url: &'static str, } /// Public-safe, compiled-in environments. `api.ote-godaddy.com` is public, and @@ -56,11 +57,13 @@ const BUILTINS: &[Builtin] = &[ name: "ote", api_url: "https://api.ote-godaddy.com", client_id: "91660d79-c909-426c-b5c8-e0f575e8fcd2", + devx_core_url: "https://api.developer.commerce.ote-godaddy.com", }, Builtin { name: "prod", api_url: "https://api.godaddy.com", client_id: "bc87f347-af82-4892-833f-818f54a0e79e", + devx_core_url: "https://api.developer.commerce.godaddy.com", }, ]; @@ -209,6 +212,17 @@ fn builtin(name: &str) -> Option<&'static Builtin> { BUILTINS.iter().find(|b| b.name == name) } +fn devx_core_url_with(name: &str, var: impl Fn(&str) -> Option) -> Option { + var("DEVX_CORE_URL") + .and_then(|value| clean_url(&value)) + .or_else(|| builtin(name).map(|env| env.devx_core_url.to_owned())) +} + +/// Base URL for the devx-core API gateway for the given environment. +pub fn devx_core_url(name: &str) -> Option { + devx_core_url_with(name, |key| std::env::var(key).ok()) +} + fn known_names(file: &EnvironmentsFile) -> String { let mut names: Vec<&str> = BUILTINS.iter().map(|b| b.name).collect(); // Only usable config entries (non-empty api_url) — match `is_known`, so the @@ -708,4 +722,32 @@ mod tests { let env = resolve_with("dev", &file, no_vars).expect("dev resolves"); assert_eq!(env.account_url, "https://account.dev.example.invalid"); } + + #[test] + fn devx_core_url_uses_prod_and_ote_builtins() { + assert_eq!( + devx_core_url_with("prod", |_| None).as_deref(), + Some("https://api.developer.commerce.godaddy.com") + ); + assert_eq!( + devx_core_url_with("ote", |_| None).as_deref(), + Some("https://api.developer.commerce.ote-godaddy.com") + ); + } + + #[test] + fn devx_core_url_global_override_wins() { + assert_eq!( + devx_core_url_with("prod", |key| { + (key == "DEVX_CORE_URL").then(|| " http://localhost:4000/ ".to_owned()) + }) + .as_deref(), + Some("http://localhost:4000") + ); + } + + #[test] + fn devx_core_url_custom_env_requires_override() { + assert_eq!(devx_core_url_with("dev", |_| None), None); + } } diff --git a/rust/src/main.rs b/rust/src/main.rs index c7f05e02..28a1d508 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -2,6 +2,7 @@ mod actions_catalog; mod api_explorer; mod application; mod auth; +mod auth_login; mod config; mod contacts; mod dns; @@ -11,6 +12,7 @@ mod environments; mod extension; mod hosting; mod next_action; +pub mod onboarding; mod output_schema; mod pat; mod payment_methods; @@ -80,7 +82,7 @@ async fn main() -> ExitCode { .with_date(env!("GDDY_BUILD_DATE")), ) .with_default_auth_provider("godaddy") - .with_auth_provider(auth_provider) + .with_auth_provider(auth_provider.clone()) .with_auth_extra_commands([scopes_cmd::auth_scopes_command()]) .with_register_flags(Arc::new(|cmd| { cmd.arg( @@ -124,5 +126,16 @@ async fn main() -> ExitCode { .with_modules(all_modules()), ); + let args: Vec = std::env::args().collect(); + if auth_login::args::parse( + &cli, + &args, + &cli_engine::default_output_format(environments::APP_ID), + ) + .is_some() + { + return auth_login::run(&cli, auth_provider.as_ref(), args).await; + } + cli.execute().await } diff --git a/rust/src/onboarding/client.rs b/rust/src/onboarding/client.rs new file mode 100644 index 00000000..24622578 --- /dev/null +++ b/rust/src/onboarding/client.rs @@ -0,0 +1,186 @@ +use serde::de::DeserializeOwned; + +use super::types::{ApiEnvelope, CliData, CliOnboardingResult, OnboardingStatus, StatusData}; + +#[derive(Debug, thiserror::Error)] +pub enum OnboardingError { + #[error("onboarding request could not be sent")] + Request(#[source] reqwest::Error), + #[error("onboarding service returned HTTP {0}")] + Http(reqwest::StatusCode), + #[error("onboarding service returned an invalid response")] + Decode(#[source] reqwest::Error), + #[error("onboarding service rejected the request")] + Unsuccessful, +} + +pub struct OnboardingClient { + base_url: String, + http: reqwest::Client, +} + +impl OnboardingClient { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_owned(), + http: reqwest::Client::new(), + } + } + + pub async fn status(&self, token: &str) -> Result { + let envelope: ApiEnvelope = + self.post("/api/v1/onboarding/status", token).await?; + if !envelope.success { + return Err(OnboardingError::Unsuccessful); + } + + Ok(OnboardingStatus { + org_id: envelope.data.id, + status: envelope.data.status, + }) + } + + pub async fn complete(&self, token: &str) -> Result { + let envelope: ApiEnvelope = self.post("/api/v1/onboarding/cli", token).await?; + if !envelope.success { + return Err(OnboardingError::Unsuccessful); + } + + Ok(CliOnboardingResult { + organization_id: envelope.data.organization_id, + status: envelope.data.status, + }) + } + + async fn post( + &self, + path: &str, + token: &str, + ) -> Result { + let response = self + .http + .post(format!("{}{path}", self.base_url)) + .bearer_auth(token) + .json(&serde_json::json!({})) + .send() + .await + .map_err(OnboardingError::Request)?; + if !response.status().is_success() { + return Err(OnboardingError::Http(response.status())); + } + + response.json().await.map_err(OnboardingError::Decode) + } +} + +#[cfg(test)] +mod tests { + use httpmock::{Method::POST, MockServer}; + use serde_json::json; + + use super::OnboardingClient; + use crate::onboarding::{CliOnboardingResult, OnboardingStatus}; + + #[tokio::test] + async fn status_posts_bearer_and_parses_response() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/api/v1/onboarding/status") + .header("authorization", "Bearer test-token") + .json_body(json!({})); + then.status(200).json_body(json!({ + "success": true, + "data": { "id": "org-1", "status": "PENDING" } + })); + }) + .await; + + let result = OnboardingClient::new(server.base_url()) + .status("test-token") + .await + .expect("status response"); + + assert_eq!( + result, + OnboardingStatus { + org_id: "org-1".to_owned(), + status: "PENDING".to_owned(), + } + ); + mock.assert_async().await; + } + + #[tokio::test] + async fn complete_posts_bearer_and_parses_response() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/api/v1/onboarding/cli") + .header("authorization", "Bearer test-token") + .json_body(json!({})); + then.status(200).json_body(json!({ + "success": true, + "data": { "organizationId": "org-1", "status": "ACTIVE" } + })); + }) + .await; + + let result = OnboardingClient::new(server.base_url()) + .complete("test-token") + .await + .expect("completion response"); + + assert_eq!( + result, + CliOnboardingResult { + organization_id: "org-1".to_owned(), + status: "ACTIVE".to_owned(), + } + ); + mock.assert_async().await; + } + + #[tokio::test] + async fn errors_do_not_include_response_body_or_token() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(500).body("secret backend detail"); + }) + .await; + + let error = OnboardingClient::new(server.base_url()) + .status("test-token") + .await + .expect_err("status must fail"); + let message = error.to_string(); + + assert!(message.contains("500")); + assert!(!message.contains("secret backend detail")); + assert!(!message.contains("test-token")); + } + + #[tokio::test] + async fn success_false_is_rejected() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": false, + "data": { "id": "org-1", "status": "PENDING" } + })); + }) + .await; + + let error = OnboardingClient::new(server.base_url()) + .status("test-token") + .await + .expect_err("unsuccessful envelope"); + assert_eq!(error.to_string(), "onboarding service rejected the request"); + } +} diff --git a/rust/src/onboarding/flow.rs b/rust/src/onboarding/flow.rs new file mode 100644 index 00000000..f4eb0dba --- /dev/null +++ b/rust/src/onboarding/flow.rs @@ -0,0 +1,85 @@ +use super::types::OnboardingStatus; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AgreementDecision { + AlreadyComplete { org_id: String }, + CompletePending, + AgreementsRequired, + UnsupportedStatus, +} + +pub fn decide( + status: &OnboardingStatus, + is_tty: bool, + accept_flag: bool, + prompt_accepted: Option, +) -> AgreementDecision { + match status.status.as_str() { + "ACTIVE" => AgreementDecision::AlreadyComplete { + org_id: status.org_id.clone(), + }, + "PENDING" if is_tty && prompt_accepted == Some(true) => AgreementDecision::CompletePending, + "PENDING" if !is_tty && accept_flag => AgreementDecision::CompletePending, + "PENDING" => AgreementDecision::AgreementsRequired, + _ => AgreementDecision::UnsupportedStatus, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::onboarding::OnboardingStatus; + + fn status(value: &str) -> OnboardingStatus { + OnboardingStatus { + org_id: "org-1".to_owned(), + status: value.to_owned(), + } + } + + #[test] + fn active_user_never_needs_acceptance() { + assert_eq!( + decide(&status("ACTIVE"), true, false, Some(false)), + AgreementDecision::AlreadyComplete { + org_id: "org-1".to_owned(), + } + ); + } + + #[test] + fn pending_non_tty_requires_flag() { + assert_eq!( + decide(&status("PENDING"), false, false, None), + AgreementDecision::AgreementsRequired + ); + } + + #[test] + fn pending_non_tty_flag_completes() { + assert_eq!( + decide(&status("PENDING"), false, true, None), + AgreementDecision::CompletePending + ); + } + + #[test] + fn pending_tty_requires_successful_prompt() { + assert_eq!( + decide(&status("PENDING"), true, true, Some(true)), + AgreementDecision::CompletePending + ); + assert_eq!( + decide(&status("PENDING"), true, true, Some(false)), + AgreementDecision::AgreementsRequired + ); + } + + #[test] + fn unknown_status_is_not_accepted_or_prompted() { + assert_eq!( + decide(&status("SUSPENDED"), true, true, Some(true)), + AgreementDecision::UnsupportedStatus + ); + } +} diff --git a/rust/src/onboarding/mod.rs b/rust/src/onboarding/mod.rs new file mode 100644 index 00000000..ed37e2b2 --- /dev/null +++ b/rust/src/onboarding/mod.rs @@ -0,0 +1,9 @@ +mod client; +mod flow; +mod prompt; +mod types; + +pub use client::{OnboardingClient, OnboardingError}; +pub use flow::{AgreementDecision, decide}; +pub use prompt::prompt_accept_agreements; +pub use types::{CliOnboardingResult, OnboardingStatus}; diff --git a/rust/src/onboarding/prompt.rs b/rust/src/onboarding/prompt.rs new file mode 100644 index 00000000..e66894a8 --- /dev/null +++ b/rust/src/onboarding/prompt.rs @@ -0,0 +1,63 @@ +use std::io::{self, BufRead, Write}; + +const TERMS_OF_USE: &str = "https://developer.commerce.godaddy.com/legal/agreements/terms-of-use"; +const PRIVACY_POLICY: &str = + "https://developer.commerce.godaddy.com/legal/agreements/privacy-policy"; +const DEVELOPER_AGREEMENT: &str = + "https://developer.commerce.godaddy.com/legal/agreements/developer-agreement"; + +pub fn prompt_accept_agreements( + reader: &mut R, + writer: &mut W, +) -> io::Result { + writeln!(writer)?; + writeln!( + writer, + "By continuing, you agree to the GoDaddy Developer terms:" + )?; + writeln!(writer)?; + writeln!(writer, " Terms of Service: {TERMS_OF_USE}")?; + writeln!(writer, " Privacy Policy: {PRIVACY_POLICY}")?; + writeln!(writer, " Developer Agreement: {DEVELOPER_AGREEMENT}")?; + writeln!(writer)?; + write!(writer, "Press Enter to accept and continue...")?; + writer.flush()?; + + let mut line = String::new(); + let bytes = reader.read_line(&mut line)?; + if bytes == 0 { + return Ok(false); + } + + Ok(line.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::prompt_accept_agreements; + + #[test] + fn prompt_writes_all_links_to_stderr_and_accepts_enter() { + let mut input = Cursor::new(b"\n"); + let mut output = Vec::new(); + assert!(prompt_accept_agreements(&mut input, &mut output).expect("prompt")); + let text = String::from_utf8(output).expect("utf8"); + assert!(text.contains("terms-of-use")); + assert!(text.contains("privacy-policy")); + assert!(text.contains("developer-agreement")); + } + + #[test] + fn prompt_fails_closed_on_eof() { + let mut input = Cursor::new([]); + assert!(!prompt_accept_agreements(&mut input, &mut Vec::new()).expect("prompt")); + } + + #[test] + fn prompt_rejects_non_empty_response() { + let mut input = Cursor::new(b"no\n"); + assert!(!prompt_accept_agreements(&mut input, &mut Vec::new()).expect("prompt")); + } +} diff --git a/rust/src/onboarding/types.rs b/rust/src/onboarding/types.rs new file mode 100644 index 00000000..9f5b12ac --- /dev/null +++ b/rust/src/onboarding/types.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OnboardingStatus { + pub org_id: String, + pub status: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CliOnboardingResult { + pub organization_id: String, + pub status: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ApiEnvelope { + pub success: bool, + pub data: T, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct StatusData { + pub id: String, + pub status: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CliData { + pub organization_id: String, + pub status: String, +}