diff --git a/docs/proposals/email-management-cli.md b/docs/proposals/email-management-cli.md new file mode 100644 index 00000000..90253f1b --- /dev/null +++ b/docs/proposals/email-management-cli.md @@ -0,0 +1,192 @@ +# Proposal: `gddy email` — mailbox management commands + +Status: draft, seeking CLI-team consensus on the open questions below. + +## Motivation + +The panel team built a new public-facing API +(`productivity-panel-api/src/server/panel-v3-api/`) that lets customers manage +GoDaddy Email mailboxes over an OAuth bearer token instead of the legacy +shopper-session `panel-api`. We want CLI parity so customers and agents can +create, list, get, and check eligibility for mailboxes directly from `gddy`, +following the same conventions as `domain`/`hosting`. The command surface is +`gddy email` — matching the `email.mailbox:*` OAuth scope family the API is +moving toward — even though the individual resources it manages are called +"mailboxes." + +The underlying API doesn't have update/delete yet: only check-eligibility, +create, list, and get are implemented server-side. The scope-authorization +middleware defines `email:update`/`email:delete`/`email:admin` scopes, but no +route currently uses them, so this proposal only covers what's actually +callable today. + +## Proposed command tree + +``` +gddy email list # GET /v3/email/mailboxes +gddy email get # GET /v3/email/mailbox/:mailboxId +gddy email create # POST /v3/email/mailboxes +gddy email check-eligibility # GET /v3/email/check-eligibility +``` + +## Per-command spec + +### `gddy email check-eligibility` + +| | | +|---|---| +| Flags | `--email ` (required) | +| Tier | `Read` | +| Scopes | `EMAIL_READ` (`email.mailbox:read`) | + +Example output: + +```json +{ + "isEligible": false, + "ineligibleReasons": ["NO_ELIGIBLE_ACCOUNT"], + "eligibleAccounts": [ + { + "accountId": "acct-123", + "requirements": [{ "agreementType": "EMAIL_TOS", "url": "https://..." }] + } + ] +} +``` + +An `accountId` here identifies an existing GoDaddy Email/productivity account +the customer already holds under panel-v3 — it is **not** a shopper/customer +ID, and it has nothing to do with domain or hosting "accounts." See +`gddy guide email-mailboxes` for how accounts, eligibility, and consent fit +together. + +When the response is ineligible, or an eligible account carries outstanding +`requirements`, attach a `next_actions` entry pointing at `email create` with +the relevant `--account-id`/`--consent` pre-filled from the response. + +### `gddy email create` + +| | | +|---|---| +| Flags | `--email ` (required), `--account-id` (an existing eligible account's ID, from `check-eligibility`'s `eligibleAccounts[].accountId` — see `gddy guide email-mailboxes`), `--first-name`, `--last-name`, repeatable `--consent ` | +| Tier | `Mutate` (`.mutates(true)`) | +| Scopes | `EMAIL_CREATE` (`email.mailbox:create`) | + +Example output: + +```json +{ + "mailboxId": "mbx-456", + "email": "someone@example.com", + "status": "PROVISIONING" +} +``` + +On a `400`/`422` business-rule failure (missing agreements, no eligible +account), surface a `fix` hint pointing at +`gddy email check-eligibility --email ` instead of a generic HTTP +error. + +### `gddy email get ` + +| | | +|---|---| +| Args | positional `mailbox-id` | +| Tier | `Read` | +| Scopes | `EMAIL_READ` | + +Example output: + +```json +{ "mailboxId": "mbx-456", "email": "someone@example.com", "status": "ACTIVE" } +``` + +### `gddy email list` + +| | | +|---|---| +| Flags | `--status`, `--fields`, `--limit`, `--offset` | +| Tier | `Read` | +| Scopes | `EMAIL_READ` | + +Example output: + +```json +[ + { "mailboxId": "mbx-456", "email": "someone@example.com", "status": "ACTIVE" } +] +``` + +`get`/`list`/`create` all attach a `next_actions` entry toward `email get +` where a mailbox ID is available in the response. + +## Open questions for the CLI team + +### 1. Feature stage: `Beta` vs `Experimental` + +`Stage::Beta` matches `hosting`'s precedent (a real spec, real tests, just +needs field usage before graduating to GA). `Stage::Experimental` matches +`platform`'s precedent (still early, likely to reshape). + +**Recommendation: `Beta`.** The API surface is small and stable relative to +what it does support; the open items below are about coordination, not about +the shape of the API changing further. + +### 2. Update/delete: omit or stub? + +The API doesn't implement `email:update`/`email:delete` routes yet, even +though the scope middleware reserves the scope names. Options: omit those +commands entirely until the API ships them, or scaffold stub commands now +that return a clear "not yet supported by the API" error. + +**Recommendation: omit entirely.** Stub commands would need their own dead +scopes (tripping the `scope_registry_non_default_entries_are_wired_to_a_command` +test, or requiring a carve-out from it) and can't be meaningfully tested +against a real API. Adding them once the routes exist is a small, low-risk +follow-up PR. + +### 3. List pagination UX: generic `--limit`/`--offset` via `PaginationConfig` (decided) + +An earlier draft of this doc claimed cli-engine's `PaginationConfig` model +requires a handler to return the *complete* list before the engine can slice +it via `--limit`/`--offset` — that's stale. `PaginationConfig` supports +`default_limit`/`max_limit`, and a handler can read +`ctx.middleware.limit`/`.offset` *before* it makes its request, so it can +drive its own server-side paging instead of always fetching everything. + +**Decision: adopt `--limit`/`--offset` via `PaginationConfig`.** `list`'s +handler translates `--limit`/`--offset` into the server's native +`page`/`pageSize` query params, fetching only as many leading pages as needed +to cover `[0, offset + limit)` — worst case, when `offset` isn't +page-aligned, that's two requests instead of one. The engine's pagination +pipeline then slices the accumulated result to the exact `--limit`/`--offset` +window. This keeps `list` consistent with the `domain list`/`dns list` +precedent instead of introducing a second, native pagination style; +`--page`/`--page-size` are dropped in favor of the generic flags. + +Forcing `--offset` to be page-aligned (e.g. rejecting a non-aligned offset +instead of silently paying for the extra request) was considered and +deferred as a possible future enhancement — it isn't required for +correctness today. + +## Cross-team coordination needed before this ships + +These aren't CLI-team decisions, but they block a working end-to-end command +and are worth surfacing here so they're tracked alongside the UX questions: + +- **Scope naming.** The CLI will request the forward-looking dotted scopes + (`email.mailbox:read`/`email.mailbox:create`), but the currently deployed + API still enforces the older flat names (`email:read`/`email:create`). + Either the panel team updates enforcement to accept the dotted names before + the CLI goes live, or the OAuth authorization server needs to be configured + to grant whichever scopes the deployed API actually checks. +- **Path prefix.** The CLI targets `/v3/email/...` (matching the OpenAPI + spec's server template), but the deployed Express app currently mounts + these routes at `/v3` directly (no `/email` segment). Until the panel team + aligns the deployed routing with the spec (or adds a `/v3/email` alias), + CLI requests will 404 against the current deployment. + +## Non-goals + +- `gddy email update` / `gddy email delete` (see open question #2). +- Any admin-scoped mailbox operations (`email:admin`) — no route exists yet. diff --git a/rust/src/email/check_eligibility.rs b/rust/src/email/check_eligibility.rs new file mode 100644 index 00000000..f2c822b9 --- /dev/null +++ b/rust/src/email/check_eligibility.rs @@ -0,0 +1,100 @@ +use cli_engine::{ + CommandResult, CommandSpec, NextAction, NextActionParam, RuntimeCommandSpec, Tier, +}; +use serde_json::Value; + +use crate::email::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::EMAIL_READ; + +#[derive(Debug, Clone, clap::Args)] +struct CheckEligibilityArgs { + #[arg(long, value_name = "EMAIL")] + email: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "check-eligibility", + "Check whether an email address is eligible for a new mailbox", + ) + .with_system("email") + .with_tier(Tier::Read) + .with_scopes(&[EMAIL_READ]), + |ctx, args: CheckEligibilityArgs| async move { + let client = make_client(&ctx, &[EMAIL_READ]).await?; + let data = client + .check_eligibility(&args.email) + .await + .map_err(client_err)?; + let next_actions = eligibility_next_actions(&args.email, &data); + Ok(CommandResult::new(data).with_next_actions(next_actions)) + }, + ) +} + +/// Always points at `email create` and prefills `--account-id` when the +/// response names an eligible account, plus a pointer at the `email-mailboxes` +/// guide for what an account ID actually is. +fn eligibility_next_actions(email: &str, data: &Value) -> Vec { + let mut create = next_action("email create", "Create a mailbox for this address") + .with_param("email", NextActionParam::value(email.to_owned())); + + if let Some(account_id) = first_eligible_account_id(data) { + create = create.with_param("account-id", NextActionParam::value(account_id)); + } + + vec![ + create, + next_action("guide email-mailboxes", "Learn about email accounts"), + ] +} + +// Every field here is read out of an untyped `serde_json::Value`, as is every +// other `EmailClient` response in this module — panel-v3-api has no published +// OpenAPI spec yet. Once it does, a generated typed client (mirroring +// `domains_client`) would remove this class of bug; not actionable today. +fn first_eligible_account_id(data: &Value) -> Option { + data.get("eligibleAccounts")? + .as_array()? + .first()? + .get("accountId")? + .as_str() + .map(str::to_owned) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn is_a_read_tier_command_scoped_to_email_read() { + let spec = command().spec; + assert_eq!(spec.tier, Some(Tier::Read)); + assert_eq!(spec.metadata().scopes, vec![EMAIL_READ.to_string()]); + } + + #[test] + fn next_actions_prefill_email_and_account_id_when_present() { + let data = json!({ + "isEligible": true, + "eligibleAccounts": [{ "accountId": "acct-1", "requirements": [] }] + }); + let actions = eligibility_next_actions("someone@example.com", &data); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].command, "gddy email create"); + assert_eq!(actions[1].command, "gddy guide email-mailboxes"); + } + + #[test] + fn next_actions_still_point_at_create_when_no_eligible_accounts() { + let data = json!({ "isEligible": false, "ineligibleReasons": ["NO_ELIGIBLE_ACCOUNT"] }); + let actions = eligibility_next_actions("someone@example.com", &data); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].command, "gddy email create"); + assert_eq!(actions[1].command, "gddy guide email-mailboxes"); + } +} diff --git a/rust/src/email/client.rs b/rust/src/email/client.rs new file mode 100644 index 00000000..342deecd --- /dev/null +++ b/rust/src/email/client.rs @@ -0,0 +1,254 @@ +use reqwest::{Client, Method}; +use serde_json::{Value, json}; + +use crate::application::client::make_http_client; + +const BASE_PATH: &str = "/v3/email"; + +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("HTTP error {status}: {body}")] + Http { status: u16, body: String }, + #[error("network error: {0}")] + Network(#[from] reqwest::Error), +} + +impl From for crate::error::GddyError { + fn from(value: ClientError) -> Self { + match value { + ClientError::Http { status, body } => Self::from_http(status, body, "email"), + ClientError::Network(e) => { + Self::network(format!("network error: {e}")).with_system("email") + } + } + } +} + +pub struct EmailClient { + client: Client, + base_url: String, + token: String, +} + +impl EmailClient { + pub fn new(base_url: impl Into, token: impl Into) -> Self { + Self { + client: make_http_client(), + base_url: base_url.into(), + token: token.into(), + } + } + + fn url(&self, path: &str) -> String { + format!("{}{BASE_PATH}{path}", self.base_url) + } + + fn new_request_id() -> String { + uuid::Uuid::new_v4().to_string() + } + + async fn send_json( + &self, + method: Method, + path: &str, + query: &[(&str, String)], + body: Option, + ) -> Result { + let mut req = self + .client + .request(method, self.url(path)) + .bearer_auth(&self.token) + .header("x-request-id", Self::new_request_id()); + for (key, value) in query { + req = req.query(&[(key, value)]); + } + if let Some(body) = body { + req = req.json(&body); + } + let request = req.build()?; + cli_engine::transport::debug_log_reqwest_request(&request); + let resp = self.client.execute(request).await?; + + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = resp.bytes().await?; + cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); + + let status = status.as_u16(); + if status == 204 { + return Ok(json!(null)); + } + if !(200..300).contains(&status) { + return Err(ClientError::Http { + status, + body: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + if bytes.is_empty() { + return Ok(json!(null)); + } + serde_json::from_slice(&bytes).map_err(|e| ClientError::Http { + status, + body: format!( + "invalid JSON response: {e} (body: {})", + String::from_utf8_lossy(&bytes) + ), + }) + } + + pub async fn list_mailboxes(&self, query: &[(&str, String)]) -> Result { + self.send_json(Method::GET, "/mailboxes", query, None).await + } + + pub async fn get_mailbox(&self, mailbox_id: &str) -> Result { + self.send_json(Method::GET, &format!("/mailbox/{mailbox_id}"), &[], None) + .await + } + + pub async fn create_mailbox(&self, body: Value) -> Result { + self.send_json(Method::POST, "/mailboxes", &[], Some(body)) + .await + } + + pub async fn check_eligibility(&self, email: &str) -> Result { + self.send_json( + Method::GET, + "/check-eligibility", + &[("email", email.to_owned())], + None, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use httpmock::prelude::*; + use serde_json::json; + + use super::*; + + fn client(base_url: &str) -> EmailClient { + EmailClient::new(base_url, "test-token") + } + + #[tokio::test] + async fn list_mailboxes_sends_bearer_auth_and_query_params() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/mailboxes") + .header("authorization", "Bearer test-token") + .query_param("status", "ACTIVE") + .query_param("page", "1"); + then.status(200).json_body(json!({ "mailboxes": [] })); + }) + .await; + + let body = client(&server.base_url()) + .list_mailboxes(&[("status", "ACTIVE".to_owned()), ("page", "1".to_owned())]) + .await + .expect("list mailboxes"); + + mock.assert_async().await; + assert_eq!(body["mailboxes"], json!([])); + } + + #[tokio::test] + async fn get_mailbox_sends_bearer_auth() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/mailbox/mbx-456") + .header("authorization", "Bearer test-token"); + then.status(200) + .json_body(json!({ "mailboxId": "mbx-456", "status": "ACTIVE" })); + }) + .await; + + let body = client(&server.base_url()) + .get_mailbox("mbx-456") + .await + .expect("get mailbox"); + + mock.assert_async().await; + assert_eq!(body["mailboxId"], "mbx-456"); + } + + #[tokio::test] + async fn create_mailbox_posts_json_body() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v3/email/mailboxes") + .header("authorization", "Bearer test-token") + .json_body(json!({ "email": "someone@example.com" })); + then.status(200) + .json_body(json!({ "mailboxId": "mbx-456", "status": "PROVISIONING" })); + }) + .await; + + let body = client(&server.base_url()) + .create_mailbox(json!({ "email": "someone@example.com" })) + .await + .expect("create mailbox"); + + mock.assert_async().await; + assert_eq!(body["status"], "PROVISIONING"); + } + + #[tokio::test] + async fn check_eligibility_sends_email_query_param() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/check-eligibility") + .header("authorization", "Bearer test-token") + .query_param("email", "someone@example.com"); + then.status(200).json_body(json!({ "isEligible": true })); + }) + .await; + + let body = client(&server.base_url()) + .check_eligibility("someone@example.com") + .await + .expect("check eligibility"); + + mock.assert_async().await; + assert_eq!(body["isEligible"], true); + } + + #[tokio::test] + async fn create_mailbox_surfaces_business_rule_error_body() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST).path("/v3/email/mailboxes"); + then.status(422).json_body(json!({ + "name": "UnprocessableEntity", + "message": "missing required agreements", + "correlationId": "corr-1", + "details": [{ "issue": "MISSING_AGREEMENT", "description": "EMAIL_TOS not accepted" }] + })); + }) + .await; + + let err = client(&server.base_url()) + .create_mailbox(json!({ "email": "someone@example.com" })) + .await + .expect_err("business-rule failure should surface as an error"); + + mock.assert_async().await; + match err { + ClientError::Http { status, body } => { + assert_eq!(status, 422); + assert!(body.contains("MISSING_AGREEMENT"), "{body}"); + } + other => panic!("unexpected: {other}"), + } + } +} diff --git a/rust/src/email/common.rs b/rust/src/email/common.rs new file mode 100644 index 00000000..62ea2038 --- /dev/null +++ b/rust/src/email/common.rs @@ -0,0 +1,147 @@ +//! Shared helpers for the `email` command group: the authenticated Email +//! (panel-v3) API client and API-error rendering. + +use cli_engine::{CliCoreError, CommandContext, Result}; +use serde::Deserialize; + +use crate::email::client::{ClientError, EmailClient}; +use crate::error::GddyError; + +pub(crate) async fn make_client(ctx: &CommandContext, scopes: &[&str]) -> Result { + let required: Vec = scopes.iter().map(|s| (*s).to_owned()).collect(); + let token = ctx.credential_with_scopes(&required).await?.token; + let base_url = crate::environments::resolve(&ctx.middleware.env)?.email_api_url; + Ok(EmailClient::new(base_url, token)) +} + +/// Maps a [`ClientError`] to a [`CliCoreError`], rendering the panel API's +/// `{message, details: [{issue, description}]}` error-body shape into a +/// human-readable message. Distinct from `domain::common::format_api_error`: +/// the panel API's error envelope doesn't carry the `fields`/402-payment +/// shape that helper is built around, so this is its own (simpler) renderer. +pub(crate) fn client_err(e: ClientError) -> CliCoreError { + match e { + ClientError::Http { status, body } => { + GddyError::from_http(status, format_api_error_body(&body), "email").into_cli_error() + } + ClientError::Network(_) => GddyError::from(e).into_cli_error(), + } +} + +/// Like [`client_err`], but overrides the fix hint. `create` uses this to +/// point business-rule failures (missing agreements, no eligible account) at +/// `email check-eligibility` instead of the generic "check request body" hint. +pub(crate) fn client_err_with_fix(e: ClientError, fix: impl Into) -> CliCoreError { + match e { + ClientError::Http { status, body } => { + GddyError::from_http(status, format_api_error_body(&body), "email") + .with_fix(fix) + .into_cli_error() + } + ClientError::Network(_) => GddyError::from(e).into_cli_error(), + } +} + +#[derive(Debug, Deserialize)] +struct ApiErrorBody { + message: Option, + #[serde(default)] + details: Vec, +} + +#[derive(Debug, Deserialize)] +struct ApiErrorDetail { + issue: Option, + description: Option, +} + +fn format_api_error_body(body: &str) -> String { + let Ok(parsed) = serde_json::from_str::(body) else { + return body.to_owned(); + }; + let message = parsed.message.unwrap_or_else(|| body.to_owned()); + let details: Vec = parsed + .details + .iter() + .filter_map(|d| d.description.clone().or_else(|| d.issue.clone())) + .filter(|s| !s.is_empty()) + .collect(); + if details.is_empty() { + message + } else { + format!("{message} ({})", details.join("; ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_api_error_body_renders_detail_descriptions() { + let body = r#"{ + "name": "UnprocessableEntity", + "message": "missing required agreements", + "correlationId": "corr-1", + "details": [{ "issue": "MISSING_AGREEMENT", "description": "EMAIL_TOS not accepted" }] + }"#; + let rendered = format_api_error_body(body); + assert_eq!( + rendered, + "missing required agreements (EMAIL_TOS not accepted)" + ); + } + + #[test] + fn format_api_error_body_falls_back_to_issue_without_description() { + let body = r#"{"message": "bad request", "details": [{"issue": "NO_ELIGIBLE_ACCOUNT"}]}"#; + assert_eq!( + format_api_error_body(body), + "bad request (NO_ELIGIBLE_ACCOUNT)" + ); + } + + #[test] + fn format_api_error_body_passes_through_unparseable_bodies() { + assert_eq!(format_api_error_body("not json"), "not json"); + } + + #[test] + fn client_err_with_fix_overrides_the_default_fix() { + let err = client_err_with_fix( + ClientError::Http { + status: 422, + body: "{\"message\": \"missing required agreements\"}".to_owned(), + }, + "Run: gddy email check-eligibility --email someone@example.com", + ); + let envelope = cli_engine::build_error_envelope(&err, "email"); + assert!( + envelope + .fix + .as_deref() + .is_some_and(|f| f.contains("check-eligibility")), + "{envelope:?}" + ); + } + + #[test] + fn client_err_maps_http_status_to_email_system() { + let err = client_err(ClientError::Http { + status: 404, + body: "{\"message\": \"mailbox not found\"}".to_owned(), + }); + let envelope = cli_engine::build_error_envelope(&err, "email"); + assert_eq!( + envelope.error.as_ref().map(|e| e.message.as_str()), + Some("HTTP error 404: mailbox not found") + ); + assert!( + envelope + .fix + .as_deref() + .is_some_and(|f| f.contains("email list")), + "{envelope:?}" + ); + } +} diff --git a/rust/src/email/create.rs b/rust/src/email/create.rs new file mode 100644 index 00000000..8af67030 --- /dev/null +++ b/rust/src/email/create.rs @@ -0,0 +1,103 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::email::client::ClientError; +use crate::email::{client_err, client_err_with_fix, make_client}; +use crate::scopes::EMAIL_CREATE; + +#[derive(Debug, Clone, clap::Args)] +struct CreateArgs { + /// Email address for the new mailbox. + #[arg(long, value_name = "EMAIL")] + email: String, + /// ID of an existing eligible account to provision this mailbox under, + /// from `check-eligibility`'s `eligibleAccounts[].accountId` (see + /// `gddy guide email-mailboxes`). Not a shopper/customer ID. + #[arg(long = "account-id", value_name = "ACCOUNT_ID")] + account_id: Option, + /// First name of the mailbox owner. + #[arg(long = "first-name", value_name = "FIRST_NAME")] + first_name: Option, + /// Last name of the mailbox owner. + #[arg(long = "last-name", value_name = "LAST_NAME")] + last_name: Option, + /// Agreement types the caller has obtained consent for, e.g. `EMAIL_TOS`. + /// Repeatable: `--consent EMAIL_TOS --consent PRIVACY_POLICY`. + #[arg(long, value_name = "AGREEMENT_TYPE")] + consent: Vec, +} + +fn request_body(args: &CreateArgs) -> Value { + let mut body = serde_json::Map::new(); + body.insert("email".to_owned(), json!(args.email)); + if let Some(account_id) = &args.account_id { + body.insert("accountId".to_owned(), json!(account_id)); + } + if let Some(first_name) = &args.first_name { + body.insert("firstName".to_owned(), json!(first_name)); + } + if let Some(last_name) = &args.last_name { + body.insert("lastName".to_owned(), json!(last_name)); + } + if !args.consent.is_empty() { + body.insert("consents".to_owned(), json!(args.consent)); + } + Value::Object(body) +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("create", "Create a new Email mailbox") + .with_system("email") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[EMAIL_CREATE]), + |ctx, args: CreateArgs| async move { + let client = make_client(&ctx, &[EMAIL_CREATE]).await?; + let body = request_body(&args); + let data = client.create_mailbox(body).await.map_err(|e| match &e { + ClientError::Http { status, .. } if *status == 400 || *status == 422 => { + client_err_with_fix( + e, + format!( + "This looks like a business-rule failure (missing agreements or no \ + eligible account). Run: gddy email check-eligibility --email {}", + args.email + ), + ) + } + _ => client_err(e), + })?; + Ok(CommandResult::new(data)) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_a_mutate_tier_command_scoped_to_email_create() { + let spec = command().spec; + assert_eq!(spec.tier, Some(Tier::Mutate)); + assert!(spec.mutates); + assert_eq!(spec.metadata().scopes, vec![EMAIL_CREATE.to_string()]); + } + + #[test] + fn request_body_includes_optional_fields_only_when_present() { + let args = CreateArgs { + email: "someone@example.com".to_owned(), + account_id: Some("acct-1".to_owned()), + first_name: None, + last_name: None, + consent: vec!["EMAIL_TOS".to_owned()], + }; + let body = request_body(&args); + assert_eq!(body["email"], "someone@example.com"); + assert_eq!(body["accountId"], "acct-1"); + assert!(body.get("firstName").is_none()); + assert_eq!(body["consents"], json!(["EMAIL_TOS"])); + } +} diff --git a/rust/src/email/get.rs b/rust/src/email/get.rs new file mode 100644 index 00000000..25ca4a1e --- /dev/null +++ b/rust/src/email/get.rs @@ -0,0 +1,39 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use crate::email::{client_err, make_client}; +use crate::scopes::EMAIL_READ; + +#[derive(Debug, Clone, clap::Args)] +struct GetArgs { + /// The mailbox ID to fetch. + mailbox_id: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get a mailbox by ID") + .with_system("email") + .with_tier(Tier::Read) + .with_scopes(&[EMAIL_READ]), + |ctx, args: GetArgs| async move { + let client = make_client(&ctx, &[EMAIL_READ]).await?; + let data = client + .get_mailbox(&args.mailbox_id) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data)) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_a_read_tier_command_scoped_to_email_read() { + let spec = command().spec; + assert_eq!(spec.tier, Some(Tier::Read)); + assert_eq!(spec.metadata().scopes, vec![EMAIL_READ.to_string()]); + } +} diff --git a/rust/src/email/guides/email-mailboxes.md b/rust/src/email/guides/email-mailboxes.md new file mode 100644 index 00000000..0618e46f --- /dev/null +++ b/rust/src/email/guides/email-mailboxes.md @@ -0,0 +1,66 @@ +--- +summary: How GoDaddy Email mailboxes, accounts, and consent fit together +--- + +# GoDaddy Email mailboxes + +This guide explains the GoDaddy email system and how to use the `gddy email` commands to manage it. + +## What an "account" is here + +An **account** (`accountId`) identifies an existing GoDaddy Email/productivity +account the customer already holds under panel-v3. It's an opaque ID scoped to +this API — it is **not** a shopper/customer ID, and it has nothing to do with +domain or hosting "accounts" elsewhere in `gddy`. A customer may hold zero, +one, or several eligible email accounts; `email create` needs to know which +one to provision the new mailbox under. + +## The check-eligibility → create flow + +Before creating a mailbox, check whether an email address is eligible and +which account(s) it can be created under: + +``` +gddy email check-eligibility --email someone@example.com +``` + +The response's `eligibleAccounts` array lists each account you can use, +together with any outstanding `requirements` (legal agreements that must be +accepted first): + +```json +{ + "isEligible": false, + "ineligibleReasons": ["NO_ELIGIBLE_ACCOUNT"], + "eligibleAccounts": [ + { + "accountId": "acct-123", + "requirements": [{ "agreementType": "EMAIL_TOS", "url": "https://..." }] + } + ] +} +``` + +Pass the account you chose, and the agreements you're accepting, straight +into `create`: + +``` +gddy email create --email someone@example.com \ + --account-id acct-123 \ + --consent EMAIL_TOS +``` + +`--consent` is repeatable — pass one per required `agreementType`. If +`create` fails with a `400`/`422` about missing agreements or no eligible +account, re-run `check-eligibility` to see the current requirements. + +## Command reference + +- `gddy email check-eligibility --email ` — see which accounts (if + any) can receive a new mailbox for this address, and what consent is + outstanding. +- `gddy email create --email [--account-id] [--first-name] + [--last-name] [--consent ]...` — provision a mailbox. +- `gddy email list [--status] [--fields] [--limit] [--offset]` — list your + mailboxes. +- `gddy email get ` — look up one mailbox by ID. diff --git a/rust/src/email/list.rs b/rust/src/email/list.rs new file mode 100644 index 00000000..8fb27726 --- /dev/null +++ b/rust/src/email/list.rs @@ -0,0 +1,257 @@ +use cli_engine::{ + CommandResult, CommandSpec, NextActionParam, PaginationConfig, RuntimeCommandSpec, Tier, +}; +use serde_json::{Value, json}; + +use crate::email::client::{ClientError, EmailClient}; +use crate::email::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::EMAIL_READ; + +/// panel-v3-api's own page-size cap for `GET /v3/email/mailboxes`. See the +/// design doc's pagination decision. +const SERVER_PAGE_SIZE_CAP: usize = 100; + +#[derive(Debug, Clone, clap::Args)] +struct ListArgs { + /// Only mailboxes with this status, e.g. ACTIVE. + #[arg(long, value_name = "STATUS")] + status: Option, + /// Comma-separated list of fields to include in the response. + #[arg(long, value_name = "FIELDS")] + fields: Option, +} + +/// Translates `--limit`/`--offset` into the panel API's native `page`/`pageSize` +/// query params via [`fetch_mailboxes`], fetching only as many leading pages as +/// needed to cover the requested window (see +/// `docs/proposals/email-management-cli.md`'s pagination decision) instead of the +/// full collection. Returns a bare JSON array so the engine's `--limit`/`--offset` +/// pipeline (`PaginationConfig`) can slice it to the exact window. +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("list", "List your Email mailboxes") + .with_system("email") + .with_tier(Tier::Read) + .with_scopes(&[EMAIL_READ]) + .with_pagination(PaginationConfig { + default_limit: 25, + max_limit: 500, + }), + |ctx, args: ListArgs| async move { + let client = make_client(&ctx, &[EMAIL_READ]).await?; + let mailboxes = fetch_mailboxes( + &client, + args.status.as_deref(), + args.fields.as_deref(), + ctx.middleware.limit, + ctx.middleware.offset, + ) + .await + .map_err(client_err)?; + Ok(CommandResult::new(json!(mailboxes)).with_next_actions(vec![ + next_action("email get ", "Get a mailbox by ID") + .with_param("mailbox-id", NextActionParam::required()), + ])) + }, + ) +} + +/// Fetches only as many leading pages as needed to cover `[offset, offset + +/// limit)`, plus one extra item beyond that window (when the server isn't +/// already exhausted) so the engine's pagination pipeline reports an accurate +/// `has_more`. `limit <= 0` means unlimited (`PaginationConfig` convention) and +/// fetches every page. Trade-off: the returned `total` reflects only what was +/// fetched, not the panel API's true mailbox count, whenever more exist beyond +/// the requested window — computing an exact `total` would require fetching +/// everything, which this early-stop is meant to avoid. +async fn fetch_mailboxes( + client: &EmailClient, + status: Option<&str>, + fields: Option<&str>, + limit: i64, + offset: i64, +) -> Result, ClientError> { + let target = if limit > 0 { + let extra = offset.saturating_add(limit).saturating_add(1); + Some(usize::try_from(extra).unwrap_or(usize::MAX)) + } else { + None + }; + + let mut mailboxes = Vec::new(); + let mut page: u32 = 1; + loop { + let mut query: Vec<(&str, String)> = vec![ + ("page", page.to_string()), + ("pageSize", SERVER_PAGE_SIZE_CAP.to_string()), + ]; + if let Some(status) = status { + query.push(("status", status.to_owned())); + } + if let Some(fields) = fields { + query.push(("fields", fields.to_owned())); + } + + let data = client.list_mailboxes(&query).await?; + let page_items = data + .get("mailboxes") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let got = page_items.len(); + mailboxes.extend(page_items); + + let exhausted = got < SERVER_PAGE_SIZE_CAP; + let covered = match target { + Some(t) => mailboxes.len() >= t, + None => false, + }; + if exhausted || covered { + break; + } + page += 1; + } + + Ok(mailboxes) +} + +#[cfg(test)] +mod tests { + use httpmock::prelude::*; + use serde_json::json; + + use super::*; + + #[test] + fn is_a_read_tier_command_scoped_to_email_read() { + let spec = command().spec; + assert_eq!(spec.tier, Some(Tier::Read)); + assert_eq!(spec.metadata().scopes, vec![EMAIL_READ.to_string()]); + } + + #[test] + fn opts_into_pagination_with_a_default_and_a_max_limit() { + assert_eq!( + command().spec.pagination, + Some(PaginationConfig { + default_limit: 25, + max_limit: 500, + }) + ); + } + + fn page_of(n: usize, start: usize) -> Vec { + (start..start + n) + .map(|i| json!({ "mailboxId": format!("mbx-{i}"), "status": "ACTIVE" })) + .collect() + } + + #[tokio::test] + async fn stops_after_one_short_page_even_when_target_is_not_yet_covered() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/mailboxes") + .query_param("page", "1"); + then.status(200) + .json_body(json!({ "mailboxes": page_of(3, 0) })); + }) + .await; + + let client = EmailClient::new(&server.base_url(), "test-token"); + let mailboxes = fetch_mailboxes(&client, None, None, 2, 0) + .await + .expect("fetch mailboxes"); + + mock.assert_calls_async(1).await; + assert_eq!(mailboxes.len(), 3); + } + + #[tokio::test] + async fn fetches_a_second_page_when_offset_and_limit_cross_a_page_boundary() { + let server = MockServer::start_async().await; + let page1 = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/mailboxes") + .query_param("page", "1"); + then.status(200) + .json_body(json!({ "mailboxes": page_of(SERVER_PAGE_SIZE_CAP, 0) })); + }) + .await; + let page2 = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/mailboxes") + .query_param("page", "2"); + then.status(200) + .json_body(json!({ "mailboxes": page_of(10, SERVER_PAGE_SIZE_CAP) })); + }) + .await; + + let client = EmailClient::new(&server.base_url(), "test-token"); + let mailboxes = fetch_mailboxes(&client, None, None, 5, 99) + .await + .expect("fetch mailboxes"); + + page1.assert_calls_async(1).await; + page2.assert_calls_async(1).await; + assert_eq!(mailboxes.len(), SERVER_PAGE_SIZE_CAP + 10); + } + + #[tokio::test] + async fn unlimited_limit_fetches_until_a_short_page_is_seen() { + let server = MockServer::start_async().await; + let page1 = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/mailboxes") + .query_param("page", "1"); + then.status(200) + .json_body(json!({ "mailboxes": page_of(SERVER_PAGE_SIZE_CAP, 0) })); + }) + .await; + let page2 = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/mailboxes") + .query_param("page", "2"); + then.status(200) + .json_body(json!({ "mailboxes": page_of(5, SERVER_PAGE_SIZE_CAP) })); + }) + .await; + + let client = EmailClient::new(&server.base_url(), "test-token"); + let mailboxes = fetch_mailboxes(&client, None, None, 0, 0) + .await + .expect("fetch mailboxes"); + + page1.assert_calls_async(1).await; + page2.assert_calls_async(1).await; + assert_eq!(mailboxes.len(), SERVER_PAGE_SIZE_CAP + 5); + } + + #[tokio::test] + async fn forwards_status_and_fields_query_params() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/mailboxes") + .query_param("status", "ACTIVE") + .query_param("fields", "mailboxId,status"); + then.status(200) + .json_body(json!({ "mailboxes": page_of(1, 0) })); + }) + .await; + + let client = EmailClient::new(&server.base_url(), "test-token"); + fetch_mailboxes(&client, Some("ACTIVE"), Some("mailboxId,status"), 10, 0) + .await + .expect("fetch mailboxes"); + + mock.assert_async().await; + } +} diff --git a/rust/src/email/mod.rs b/rust/src/email/mod.rs new file mode 100644 index 00000000..fb5a49bb --- /dev/null +++ b/rust/src/email/mod.rs @@ -0,0 +1,88 @@ +pub mod client; +mod common; + +mod check_eligibility; +mod create; +mod get; +mod list; + +pub(crate) use common::{client_err, client_err_with_fix, make_client}; + +use cli_engine::{GroupSpec, Module, RuntimeGroupSpec, Stage}; + +pub fn module() -> Module { + Module::new("Email", |_ctx| { + RuntimeGroupSpec::new( + GroupSpec::new("email", "Create, list, and inspect GoDaddy Email mailboxes").with_long( + "Manage GoDaddy Email mailboxes over panel-v3.\n\ + \n\ + • check-eligibility — see which account(s) an address can be created\n\ + \x20 under, and what consent is outstanding\n\ + • create — provision a mailbox\n\ + • list / get — your existing mailboxes and their details\n\ + \n\ + See `gddy guide email-mailboxes` for what an account ID is and how the\n\ + check-eligibility → create flow works.", + ), + ) + .with_command(list::command()) + .with_command(get::command()) + .with_command(create::command()) + .with_command(check_eligibility::command()) + }) + .with_feature_flag("email", Stage::Beta) + .with_guides_from_markdown([( + "email-mailboxes.md", + include_bytes!("guides/email-mailboxes.md").as_slice(), + )]) +} + +#[cfg(test)] +mod tests { + use cli_engine::{Cli, CliConfig, Stage}; + + #[tokio::test] + async fn email_commands_require_auth() { + const AUTH_FAILURE_EXIT: i32 = 2; + let cases: [&[&str]; 4] = [ + &["gddy", "email", "list", "--output", "json"], + &["gddy", "email", "get", "mbx-456", "--output", "json"], + &[ + "gddy", + "email", + "create", + "--email", + "someone@example.com", + "--output", + "json", + ], + &[ + "gddy", + "email", + "check-eligibility", + "--email", + "someone@example.com", + "--output", + "json", + ], + ]; + + for args in cases { + let cli = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_min_stage(Stage::Beta) + .with_default_auth_provider("godaddy") + .with_module(super::module()), + ); + let output = cli.run(args.iter().copied()).await; + assert_eq!( + output.exit_code, AUTH_FAILURE_EXIT, + "args {args:?} -> {output:?}" + ); + let json: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json output"); + let message = json["error"]["message"].as_str().unwrap_or_default(); + assert!(message.contains("provider"), "args {args:?} -> {message:?}"); + } + } +} diff --git a/rust/src/environments/mod.rs b/rust/src/environments/mod.rs index 0e3e0313..9790b166 100644 --- a/rust/src/environments/mod.rs +++ b/rust/src/environments/mod.rs @@ -127,6 +127,21 @@ pub struct GddyEnvConfig { default_fn = default_account_url )] pub account_url: String, + + /// Base URL for the email (panel-v3) API. Defaults to + /// `productivity.api.godaddy.com` for prod, `productivity.api.test-godaddy.com` + /// for test, and `productivity.api.stg-godaddy.com` for stage — not the + /// generic `{env}-godaddy.com` convention, since `stage`'s real host uses + /// `stg-` and there's no dedicated OTE deployment (`ote` aliases to + /// `test`). Any other environment falls back to `api_url`. Overridable at + /// runtime via `GDDY_EMAIL_API_URL` or local config. + #[env_config( + from_toml = parse_url_from_toml, + env = "EMAIL_API_URL", + from_env = parse_url, + default_fn = default_email_api_url + )] + pub email_api_url: String, } pub fn env_prefix(name: &str) -> String { @@ -172,6 +187,26 @@ fn default_account_url(sources: &SourceChain<'_>) -> String { derive_account_url(sources.env_name().unwrap_or_default()) } +/// Compiled-in per-environment hosts for the panel-v3 email API — see the +/// doc comment on [`GddyEnvConfig::email_api_url`] for why this can't be +/// derived from `api_url` via the generic host-substitution convention. +const BUILTIN_EMAIL_API_URLS: &[(&str, &str)] = &[ + ("prod", "https://productivity.api.godaddy.com"), + ("test", "https://productivity.api.test-godaddy.com"), + ("stage", "https://productivity.api.stg-godaddy.com"), + // No dedicated OTE deployment of this API; alias to `test`. + ("ote", "https://productivity.api.test-godaddy.com"), +]; + +fn default_email_api_url(sources: &SourceChain<'_>) -> String { + let env_name = sources.env_name().unwrap_or_default(); + BUILTIN_EMAIL_API_URLS + .iter() + .find(|(name, _)| *name == env_name) + .map(|(_, url)| (*url).to_owned()) + .unwrap_or_else(|| current_api_url(sources)) +} + fn derive_account_url(env_name: &str) -> String { if env_name == "prod" { return "https://account.godaddy.com".to_owned(); @@ -394,566 +429,4 @@ pub fn is_known(name: &str) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use cli_engine::environments::EnvTable; - use std::sync::Mutex; - - // Serializes every test that touches real process env vars, so - // parallel test threads can't observe each other's GDDY_* overrides. - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - /// RAII guard that sets an env var and restores it to its prior state on - /// drop — removing it if it wasn't already set, or putting the original - /// value back if it was — even if a test panics. Restoring rather than - /// unconditionally removing keeps a var a developer happens to already - /// have set in their shell from leaking into the rest of the test run. - struct EnvGuard { - key: &'static str, - prior: Option, - } - impl EnvGuard { - fn set(key: &'static str, value: &str) -> Self { - let prior = std::env::var(key).ok(); - // SAFETY: caller holds ENV_LOCK. - #[allow(unsafe_code)] - unsafe { - std::env::set_var(key, value); - } - Self { key, prior } - } - } - impl Drop for EnvGuard { - fn drop(&mut self) { - // SAFETY: caller holds ENV_LOCK; restore on any exit incl. panic. - #[allow(unsafe_code)] - unsafe { - match &self.prior { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - } - - #[test] - fn register_scaffolds_a_file_only_environment() { - // Resolves through `register()`, which sets `app_id` — so it checks - // `GDDY_*` overrides and must be serialized against tests that set - // them (see `ENV_LOCK`'s own doc). - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let dir = tempfile::tempdir().expect("tempdir"); - let file = dir.path().join("environments.toml"); - std::fs::write( - &file, - r#" -[dev] -api_url = "https://api.dev-godaddy.com" -client_id = "dev-client" -"#, - ) - .expect("write file"); - - let envs = register(Environments::new("prod").with_config_file_path_override(file)); - let resolved: GddyEnvConfig = envs.resolve("dev").expect("dev resolves"); - - assert_eq!(resolved.domains_api_url, "https://api.dev-godaddy.com"); - assert_eq!(resolved.account_url, "https://account.dev-godaddy.com"); - } - - #[test] - fn register_rejects_a_file_only_environments_malformed_api_url() { - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let dir = tempfile::tempdir().expect("tempdir"); - let file = dir.path().join("environments.toml"); - std::fs::write( - &file, - r#" -[dev] -api_url = "not-a-url" -client_id = "dev-client" -"#, - ) - .expect("write file"); - - let envs = register(Environments::new("prod").with_config_file_path_override(file)); - let err = envs - .resolve::("dev") - .expect_err("a malformed api_url must be a hard error, not silently dropped"); - assert!(err.to_string().contains("api_url")); - } - - #[test] - fn register_rejects_a_malformed_file_layer_auth_url_override_for_a_builtin() { - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let dir = tempfile::tempdir().expect("tempdir"); - let file = dir.path().join("environments.toml"); - std::fs::write( - &file, - r#" -[prod] -auth_url = "not-a-url" -"#, - ) - .expect("write file"); - - let envs = register(Environments::new("prod").with_config_file_path_override(file)); - let err = envs - .resolve::("prod") - .expect_err("a malformed override must be a hard error"); - assert!(err.to_string().contains("auth_url")); - } - - fn test_environment(name: &str, extend: impl FnOnce(EnvTable) -> EnvTable) -> GddyEnvConfig { - Environments::new(name) - .with_environment(name, extend(EnvTable::new())) - .resolve(name) - .expect("resolves") - } - - #[test] - fn resolved_env_derives_oauth_urls_from_api_url_when_unset() { - let resolved = test_environment("dev", |t| { - t.with("client_id", "cid") - .with("api_url", "https://api.example.test") - }); - assert_eq!(resolved.name, "dev"); - assert_eq!(resolved.api_url, "https://api.example.test"); - assert_eq!(resolved.client_id, "cid"); - assert_eq!( - resolved.auth_url, - "https://api.example.test/v2/oauth2/authorize" - ); - assert_eq!( - resolved.token_url, - "https://api.example.test/v2/oauth2/token" - ); - } - - #[test] - fn resolved_env_prefers_explicit_oauth_urls_over_derived() { - let resolved = test_environment("dev", |t| { - t.with("client_id", "cid") - .with("auth_url", "https://auth.example.test/authorize") - .with("token_url", "https://auth.example.test/token") - .with("api_url", "https://api.example.test") - }); - assert_eq!(resolved.auth_url, "https://auth.example.test/authorize"); - assert_eq!(resolved.token_url, "https://auth.example.test/token"); - } - - #[test] - fn resolved_env_falls_back_to_derived_oauth_urls_when_override_is_blank() { - // A blank `auth_url` (from a TOML value here, or from `GDDY_AUTH_URL=" "` - // — see `blank_env_var_override_falls_through_to_derived_auth_url`) is - // treated the same as unset. A genuinely malformed (non-blank) override - // is a hard resolve error instead — see - // `register_rejects_a_malformed_file_layer_auth_url_override_for_a_builtin`. - let resolved = test_environment("dev", |t| { - t.with("client_id", "cid") - .with("auth_url", " ") - .with("api_url", "https://api.example.test") - }); - assert_eq!( - resolved.auth_url, - "https://api.example.test/v2/oauth2/authorize" - ); - } - - #[test] - fn resolve_rejects_missing_api_url() { - let err = Environments::new("dev") - .with_environment("dev", EnvTable::new().with("client_id", "cid")) - .resolve::("dev") - .expect_err("no api_url"); - assert!(err.to_string().contains("api_url")); - } - - #[test] - fn resolve_rejects_missing_client_id() { - // client_id has no default: every environment (built-in, file, or - // hand-built for a test) must supply a real one. - let err = Environments::new("dev") - .with_environment( - "dev", - EnvTable::new().with("api_url", "https://api.example.test"), - ) - .resolve::("dev") - .expect_err("no client_id"); - assert!(err.to_string().contains("client_id")); - } - - #[test] - fn resolve_rejects_blank_api_url_final_value() { - // Unlike auth_url/token_url/domains_api_url/account_url, api_url has - // no sensible derived fallback, so blank is rejected (as `MissingField`, - // since a blank source answer is treated as absent by default). - let err = Environments::new("dev") - .with_environment( - "dev", - EnvTable::new() - .with("client_id", "cid") - .with("api_url", " "), - ) - .resolve::("dev") - .expect_err("blank api_url must be rejected"); - assert!(err.to_string().contains("api_url")); - } - - #[test] - fn domains_api_url_defaults_to_api_url() { - let resolved = test_environment("dev", |t| { - t.with("client_id", "cid") - .with("api_url", "https://api.example.test") - }); - assert_eq!(resolved.domains_api_url, resolved.api_url); - } - - #[test] - fn domains_api_url_override_is_respected() { - let resolved = test_environment("dev", |t| { - t.with("client_id", "cid") - .with("api_url", "https://api.example.test") - .with("domains_api_url", "https://domains.example.test") - }); - assert_eq!(resolved.domains_api_url, "https://domains.example.test"); - } - - #[test] - fn account_url_defaults_to_bare_domain_for_prod() { - let resolved = test_environment("prod", |t| { - t.with("client_id", "cid") - .with("api_url", "https://api.godaddy.com") - }); - assert_eq!(resolved.account_url, "https://account.godaddy.com"); - } - - #[test] - fn account_url_defaults_to_prefixed_domain_for_non_prod() { - let resolved = test_environment("ote", |t| { - t.with("client_id", "cid") - .with("api_url", "https://api.ote-godaddy.com") - }); - assert_eq!(resolved.account_url, "https://account.ote-godaddy.com"); - } - - #[test] - fn account_url_override_is_respected() { - let resolved = test_environment("dev", |t| { - t.with("client_id", "cid") - .with("api_url", "https://api.example.test") - .with("account_url", "https://account.override.test") - }); - assert_eq!(resolved.account_url, "https://account.override.test"); - } - - fn test_environment_with_app_id( - name: &str, - extend: impl FnOnce(EnvTable) -> EnvTable, - ) -> GddyEnvConfig { - Environments::new(name) - .with_app_id(APP_ID) - .with_environment(name, extend(EnvTable::new())) - .resolve(name) - .expect("resolves") - } - - #[test] - fn env_var_overrides_auth_url() { - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvGuard::set("GDDY_AUTH_URL", "https://auth.override.test"); - - let resolved = test_environment_with_app_id("dev", |t| { - t.with("client_id", "cid") - .with("api_url", "https://api.example.test") - .with("auth_url", "https://auth.example.test/authorize") - }); - assert_eq!( - resolved.auth_url, "https://auth.override.test", - "env var must win over the TOML value" - ); - } - - #[test] - fn env_var_overrides_token_url() { - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvGuard::set("GDDY_TOKEN_URL", "https://token.override.test"); - - let resolved = test_environment_with_app_id("dev", |t| { - t.with("client_id", "cid") - .with("api_url", "https://api.example.test") - }); - assert_eq!(resolved.token_url, "https://token.override.test"); - } - - #[test] - fn env_var_overrides_domains_api_url() { - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvGuard::set("GDDY_DOMAINS_API_URL", "https://domains.override.test"); - - let resolved = test_environment_with_app_id("dev", |t| { - t.with("client_id", "cid") - .with("api_url", "https://api.example.test") - }); - assert_eq!(resolved.domains_api_url, "https://domains.override.test"); - } - - #[test] - fn env_var_overrides_account_url() { - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvGuard::set("GDDY_ACCOUNT_URL", "https://account.override.test"); - - let resolved = test_environment_with_app_id("dev", |t| { - t.with("client_id", "cid") - .with("api_url", "https://api.example.test") - }); - assert_eq!(resolved.account_url, "https://account.override.test"); - } - - #[test] - fn substitute_env_host_prefixes_a_bare_domain() { - assert_eq!( - substitute_env_host("https://godaddy.com", "ote"), - Some("https://ote-godaddy.com".to_owned()) - ); - } - - #[test] - fn substitute_env_host_preserves_subdomain_and_path() { - assert_eq!( - substitute_env_host( - "https://fulfillment.api.commerce.godaddy.com/v1/commerce", - "dev" - ), - Some("https://fulfillment.api.commerce.dev-godaddy.com/v1/commerce".to_owned()) - ); - } - - #[test] - fn substitute_env_host_returns_none_for_non_godaddy_host() { - assert_eq!(substitute_env_host("https://example.com/v1", "ote"), None); - } - - #[test] - fn domain_override_falls_back_to_env_var_when_no_local_config_entry() { - // "fulfillments-catalog-test" is not a compiled builtin and has no - // local config entry, so the first (local-config) branch misses and - // the injected var getter is consulted directly. - let var = |k: &str| { - (k == "FULFILLMENTS_CATALOG_TEST_FULFILLMENTS_API_URL") - .then(|| "https://fulfillments.example.test".to_owned()) - }; - let resolved = domain_override("fulfillments_api_url", "fulfillments-catalog-test", var); - assert_eq!( - resolved, - Some("https://fulfillments.example.test".to_owned()) - ); - } - - #[test] - fn domain_override_is_none_when_neither_layer_has_it() { - let resolved = domain_override("fulfillments_api_url", "fulfillments-catalog-test", |_| { - None - }); - assert_eq!(resolved, None); - } - - #[test] - fn resolve_catalog_base_url_returns_prod_unchanged() { - let url = resolve_catalog_base_url( - "fulfillments", - "https://fulfillment.api.commerce.godaddy.com/v1/commerce", - "prod", - ); - assert_eq!( - url, - "https://fulfillment.api.commerce.godaddy.com/v1/commerce" - ); - } - - #[test] - fn resolve_catalog_base_url_applies_convention_for_non_prod() { - // No override exists anywhere for this made-up env/domain pair, so - // this exercises the `{env}-godaddy.com` convention fallback. - let url = resolve_catalog_base_url( - "fulfillments", - "https://fulfillment.api.commerce.godaddy.com/v1/commerce", - "ote", - ); - assert_eq!( - url, - "https://fulfillment.api.commerce.ote-godaddy.com/v1/commerce" - ); - } - - #[test] - fn env_var_override_rejects_a_malformed_url() { - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvGuard::set("GDDY_AUTH_URL", "not-a-url"); - - let err = Environments::new("dev") - .with_app_id(APP_ID) - .with_environment( - "dev", - EnvTable::new() - .with("client_id", "cid") - .with("api_url", "https://api.example.test"), - ) - .resolve::("dev") - .expect_err("a malformed env var override must be a hard error"); - assert!(err.to_string().contains("auth_url")); - } - - #[test] - fn blank_env_var_override_falls_through_to_derived_auth_url() { - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvGuard::set("GDDY_AUTH_URL", " "); - - let resolved = test_environment_with_app_id("dev", |t| { - t.with("client_id", "cid") - .with("api_url", "https://api.example.test") - }); - assert_eq!( - resolved.auth_url, "https://api.example.test/v2/oauth2/authorize", - "a blank env var override is treated as absent, same as a blank TOML value" - ); - } - - #[test] - fn clean_url_requires_a_non_empty_host() { - assert_eq!( - clean_url("https://api.example.test/"), - Some("https://api.example.test".to_owned()) - ); - assert!(clean_url("https:///path").is_none()); - assert!(clean_url("https://").is_none()); - assert!(clean_url("https://?x").is_none()); - assert!(clean_url("ftp://x").is_none()); - assert!(clean_url("api.example.test").is_none()); - assert!(clean_url("not a url").is_none()); - assert_eq!( - clean_url("HTTPS://api.Example.test"), - Some("HTTPS://api.Example.test".to_owned()) - ); - assert_eq!( - clean_url("http://localhost:8080/api/"), - Some("http://localhost:8080/api".to_owned()) - ); - } - - #[test] - fn env_prefix_uppercases_and_replaces_hyphen() { - assert_eq!(env_prefix("ote"), "OTE"); - assert_eq!(env_prefix("prod-us"), "PROD_US"); - } - - #[test] - fn resolve_default_environments_falls_back_to_default_env_for_an_unresolvable_gdenv_value() { - // A corrupted/stale `.gdenv` value that resolves to nothing (no - // compiled/file entry) must not become the CLI's real startup - // default. - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let envs = resolve_default_environments("totally-bogus-env-name"); - assert_eq!(envs.default_env(), DEFAULT_ENV); - assert!(envs.source(DEFAULT_ENV).is_ok()); - } - - #[test] - fn source_existing_but_resolve_failing_is_the_case_resolve_default_environments_must_catch() { - // `resolve_default_environments`'s own validity check must use - // `.resolve::()`, not `.source()` — a name can be - // *known* to a layer (so `.source()` succeeds) while still missing - // required fields (so `.resolve()` fails). Checking only `.source()` - // would let a misconfigured persisted default stick, instead of - // falling back to `DEFAULT_ENV`. - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // A file-path override pointing at a nonexistent path, not a bare - // `register(...)` — without it this would pick up a real developer's - // own `~/.config/gddy/environments.toml` `[dev]` entry (if any), - // making the test's result depend on that machine's local config. - let dir = tempfile::tempdir().expect("tempdir"); - let missing_file = dir.path().join("environments.toml"); - let probe = register(Environments::new("dev").with_config_file_path_override(missing_file)) - .with_environment( - "dev", - EnvTable::new().with("api_url", "https://api.example.test"), // no client_id - ); - assert!(probe.source("dev").is_ok(), "the name is known"); - assert!( - probe.resolve::("dev").is_err(), - "but it's missing client_id, so it can't actually assemble" - ); - } - - #[test] - fn resolve_default_environments_keeps_a_resolvable_gdenv_value() { - let _g = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let envs = resolve_default_environments("prod"); - assert_eq!(envs.default_env(), "prod"); - } - - #[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_per_environment_override_wins_over_global() { - assert_eq!( - devx_core_url_with("dev", |key| match key { - "DEV_DEVX_CORE_URL" => Some("https://dev-core.example.test/".to_owned()), - "DEVX_CORE_URL" => Some("https://shared-core.example.test".to_owned()), - _ => None, - }) - .as_deref(), - Some("https://dev-core.example.test") - ); - } - - #[test] - fn devx_core_url_custom_env_requires_override() { - assert_eq!(devx_core_url_with("dev", |_| None), None); - } -} +mod tests; diff --git a/rust/src/environments/tests.rs b/rust/src/environments/tests.rs new file mode 100644 index 00000000..7583991d --- /dev/null +++ b/rust/src/environments/tests.rs @@ -0,0 +1,621 @@ +use super::*; +use cli_engine::environments::EnvTable; +use std::sync::Mutex; + +// Serializes every test that touches real process env vars, so +// parallel test threads can't observe each other's GDDY_* overrides. +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +/// RAII guard that sets an env var and restores it to its prior state on +/// drop — removing it if it wasn't already set, or putting the original +/// value back if it was — even if a test panics. Restoring rather than +/// unconditionally removing keeps a var a developer happens to already +/// have set in their shell from leaking into the rest of the test run. +struct EnvGuard { + key: &'static str, + prior: Option, +} +impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let prior = std::env::var(key).ok(); + // SAFETY: caller holds ENV_LOCK. + #[allow(unsafe_code)] + unsafe { + std::env::set_var(key, value); + } + Self { key, prior } + } +} +impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: caller holds ENV_LOCK; restore on any exit incl. panic. + #[allow(unsafe_code)] + unsafe { + match &self.prior { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} + +#[test] +fn register_scaffolds_a_file_only_environment() { + // Resolves through `register()`, which sets `app_id` — so it checks + // `GDDY_*` overrides and must be serialized against tests that set + // them (see `ENV_LOCK`'s own doc). + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("environments.toml"); + std::fs::write( + &file, + r#" +[dev] +api_url = "https://api.dev-godaddy.com" +client_id = "dev-client" +"#, + ) + .expect("write file"); + + let envs = register(Environments::new("prod").with_config_file_path_override(file)); + let resolved: GddyEnvConfig = envs.resolve("dev").expect("dev resolves"); + + assert_eq!(resolved.domains_api_url, "https://api.dev-godaddy.com"); + assert_eq!(resolved.account_url, "https://account.dev-godaddy.com"); +} + +#[test] +fn register_rejects_a_file_only_environments_malformed_api_url() { + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("environments.toml"); + std::fs::write( + &file, + r#" +[dev] +api_url = "not-a-url" +client_id = "dev-client" +"#, + ) + .expect("write file"); + + let envs = register(Environments::new("prod").with_config_file_path_override(file)); + let err = envs + .resolve::("dev") + .expect_err("a malformed api_url must be a hard error, not silently dropped"); + assert!(err.to_string().contains("api_url")); +} + +#[test] +fn register_rejects_a_malformed_file_layer_auth_url_override_for_a_builtin() { + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("environments.toml"); + std::fs::write( + &file, + r#" +[prod] +auth_url = "not-a-url" +"#, + ) + .expect("write file"); + + let envs = register(Environments::new("prod").with_config_file_path_override(file)); + let err = envs + .resolve::("prod") + .expect_err("a malformed override must be a hard error"); + assert!(err.to_string().contains("auth_url")); +} + +fn test_environment(name: &str, extend: impl FnOnce(EnvTable) -> EnvTable) -> GddyEnvConfig { + Environments::new(name) + .with_environment(name, extend(EnvTable::new())) + .resolve(name) + .expect("resolves") +} + +#[test] +fn resolved_env_derives_oauth_urls_from_api_url_when_unset() { + let resolved = test_environment("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.name, "dev"); + assert_eq!(resolved.api_url, "https://api.example.test"); + assert_eq!(resolved.client_id, "cid"); + assert_eq!( + resolved.auth_url, + "https://api.example.test/v2/oauth2/authorize" + ); + assert_eq!( + resolved.token_url, + "https://api.example.test/v2/oauth2/token" + ); +} + +#[test] +fn resolved_env_prefers_explicit_oauth_urls_over_derived() { + let resolved = test_environment("dev", |t| { + t.with("client_id", "cid") + .with("auth_url", "https://auth.example.test/authorize") + .with("token_url", "https://auth.example.test/token") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.auth_url, "https://auth.example.test/authorize"); + assert_eq!(resolved.token_url, "https://auth.example.test/token"); +} + +#[test] +fn resolved_env_falls_back_to_derived_oauth_urls_when_override_is_blank() { + // A blank `auth_url` (from a TOML value here, or from `GDDY_AUTH_URL=" "` + // — see `blank_env_var_override_falls_through_to_derived_auth_url`) is + // treated the same as unset. A genuinely malformed (non-blank) override + // is a hard resolve error instead — see + // `register_rejects_a_malformed_file_layer_auth_url_override_for_a_builtin`. + let resolved = test_environment("dev", |t| { + t.with("client_id", "cid") + .with("auth_url", " ") + .with("api_url", "https://api.example.test") + }); + assert_eq!( + resolved.auth_url, + "https://api.example.test/v2/oauth2/authorize" + ); +} + +#[test] +fn resolve_rejects_missing_api_url() { + let err = Environments::new("dev") + .with_environment("dev", EnvTable::new().with("client_id", "cid")) + .resolve::("dev") + .expect_err("no api_url"); + assert!(err.to_string().contains("api_url")); +} + +#[test] +fn resolve_rejects_missing_client_id() { + // client_id has no default: every environment (built-in, file, or + // hand-built for a test) must supply a real one. + let err = Environments::new("dev") + .with_environment( + "dev", + EnvTable::new().with("api_url", "https://api.example.test"), + ) + .resolve::("dev") + .expect_err("no client_id"); + assert!(err.to_string().contains("client_id")); +} + +#[test] +fn resolve_rejects_blank_api_url_final_value() { + // Unlike auth_url/token_url/domains_api_url/account_url, api_url has + // no sensible derived fallback, so blank is rejected (as `MissingField`, + // since a blank source answer is treated as absent by default). + let err = Environments::new("dev") + .with_environment( + "dev", + EnvTable::new() + .with("client_id", "cid") + .with("api_url", " "), + ) + .resolve::("dev") + .expect_err("blank api_url must be rejected"); + assert!(err.to_string().contains("api_url")); +} + +#[test] +fn domains_api_url_defaults_to_api_url() { + let resolved = test_environment("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.domains_api_url, resolved.api_url); +} + +#[test] +fn domains_api_url_override_is_respected() { + let resolved = test_environment("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + .with("domains_api_url", "https://domains.example.test") + }); + assert_eq!(resolved.domains_api_url, "https://domains.example.test"); +} + +#[test] +fn account_url_defaults_to_bare_domain_for_prod() { + let resolved = test_environment("prod", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.godaddy.com") + }); + assert_eq!(resolved.account_url, "https://account.godaddy.com"); +} + +#[test] +fn account_url_defaults_to_prefixed_domain_for_non_prod() { + let resolved = test_environment("ote", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.ote-godaddy.com") + }); + assert_eq!(resolved.account_url, "https://account.ote-godaddy.com"); +} + +#[test] +fn account_url_override_is_respected() { + let resolved = test_environment("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + .with("account_url", "https://account.override.test") + }); + assert_eq!(resolved.account_url, "https://account.override.test"); +} + +#[test] +fn email_api_url_resolves_known_environments() { + for (name, url) in [ + ("prod", "https://productivity.api.godaddy.com"), + ("test", "https://productivity.api.test-godaddy.com"), + ("stage", "https://productivity.api.stg-godaddy.com"), + ] { + let resolved = test_environment(name, |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.email_api_url, url, "environment {name:?}"); + } +} + +#[test] +fn email_api_url_aliases_ote_to_test() { + let resolved = test_environment("ote", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.ote-godaddy.com") + }); + assert_eq!( + resolved.email_api_url, + "https://productivity.api.test-godaddy.com" + ); +} + +#[test] +fn email_api_url_falls_back_to_api_url_for_an_unknown_environment() { + let resolved = test_environment("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.email_api_url, "https://api.example.test"); +} + +#[test] +fn email_api_url_override_is_respected() { + let resolved = test_environment("prod", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + .with("email_api_url", "https://email.override.test") + }); + assert_eq!(resolved.email_api_url, "https://email.override.test"); +} + +#[test] +fn env_var_overrides_email_api_url() { + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvGuard::set("GDDY_EMAIL_API_URL", "https://email.override.test"); + + let resolved = test_environment_with_app_id("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.email_api_url, "https://email.override.test"); +} + +fn test_environment_with_app_id( + name: &str, + extend: impl FnOnce(EnvTable) -> EnvTable, +) -> GddyEnvConfig { + Environments::new(name) + .with_app_id(APP_ID) + .with_environment(name, extend(EnvTable::new())) + .resolve(name) + .expect("resolves") +} + +#[test] +fn env_var_overrides_auth_url() { + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvGuard::set("GDDY_AUTH_URL", "https://auth.override.test"); + + let resolved = test_environment_with_app_id("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + .with("auth_url", "https://auth.example.test/authorize") + }); + assert_eq!( + resolved.auth_url, "https://auth.override.test", + "env var must win over the TOML value" + ); +} + +#[test] +fn env_var_overrides_token_url() { + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvGuard::set("GDDY_TOKEN_URL", "https://token.override.test"); + + let resolved = test_environment_with_app_id("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.token_url, "https://token.override.test"); +} + +#[test] +fn env_var_overrides_domains_api_url() { + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvGuard::set("GDDY_DOMAINS_API_URL", "https://domains.override.test"); + + let resolved = test_environment_with_app_id("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.domains_api_url, "https://domains.override.test"); +} + +#[test] +fn env_var_overrides_account_url() { + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvGuard::set("GDDY_ACCOUNT_URL", "https://account.override.test"); + + let resolved = test_environment_with_app_id("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.account_url, "https://account.override.test"); +} + +#[test] +fn substitute_env_host_prefixes_a_bare_domain() { + assert_eq!( + substitute_env_host("https://godaddy.com", "ote"), + Some("https://ote-godaddy.com".to_owned()) + ); +} + +#[test] +fn substitute_env_host_preserves_subdomain_and_path() { + assert_eq!( + substitute_env_host( + "https://fulfillment.api.commerce.godaddy.com/v1/commerce", + "dev" + ), + Some("https://fulfillment.api.commerce.dev-godaddy.com/v1/commerce".to_owned()) + ); +} + +#[test] +fn substitute_env_host_returns_none_for_non_godaddy_host() { + assert_eq!(substitute_env_host("https://example.com/v1", "ote"), None); +} + +#[test] +fn domain_override_falls_back_to_env_var_when_no_local_config_entry() { + // "fulfillments-catalog-test" is not a compiled builtin and has no + // local config entry, so the first (local-config) branch misses and + // the injected var getter is consulted directly. + let var = |k: &str| { + (k == "FULFILLMENTS_CATALOG_TEST_FULFILLMENTS_API_URL") + .then(|| "https://fulfillments.example.test".to_owned()) + }; + let resolved = domain_override("fulfillments_api_url", "fulfillments-catalog-test", var); + assert_eq!( + resolved, + Some("https://fulfillments.example.test".to_owned()) + ); +} + +#[test] +fn domain_override_is_none_when_neither_layer_has_it() { + let resolved = domain_override("fulfillments_api_url", "fulfillments-catalog-test", |_| { + None + }); + assert_eq!(resolved, None); +} + +#[test] +fn resolve_catalog_base_url_returns_prod_unchanged() { + let url = resolve_catalog_base_url( + "fulfillments", + "https://fulfillment.api.commerce.godaddy.com/v1/commerce", + "prod", + ); + assert_eq!( + url, + "https://fulfillment.api.commerce.godaddy.com/v1/commerce" + ); +} + +#[test] +fn resolve_catalog_base_url_applies_convention_for_non_prod() { + // No override exists anywhere for this made-up env/domain pair, so + // this exercises the `{env}-godaddy.com` convention fallback. + let url = resolve_catalog_base_url( + "fulfillments", + "https://fulfillment.api.commerce.godaddy.com/v1/commerce", + "ote", + ); + assert_eq!( + url, + "https://fulfillment.api.commerce.ote-godaddy.com/v1/commerce" + ); +} + +#[test] +fn env_var_override_rejects_a_malformed_url() { + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvGuard::set("GDDY_AUTH_URL", "not-a-url"); + + let err = Environments::new("dev") + .with_app_id(APP_ID) + .with_environment( + "dev", + EnvTable::new() + .with("client_id", "cid") + .with("api_url", "https://api.example.test"), + ) + .resolve::("dev") + .expect_err("a malformed env var override must be a hard error"); + assert!(err.to_string().contains("auth_url")); +} + +#[test] +fn blank_env_var_override_falls_through_to_derived_auth_url() { + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvGuard::set("GDDY_AUTH_URL", " "); + + let resolved = test_environment_with_app_id("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!( + resolved.auth_url, "https://api.example.test/v2/oauth2/authorize", + "a blank env var override is treated as absent, same as a blank TOML value" + ); +} + +#[test] +fn clean_url_requires_a_non_empty_host() { + assert_eq!( + clean_url("https://api.example.test/"), + Some("https://api.example.test".to_owned()) + ); + assert!(clean_url("https:///path").is_none()); + assert!(clean_url("https://").is_none()); + assert!(clean_url("https://?x").is_none()); + assert!(clean_url("ftp://x").is_none()); + assert!(clean_url("api.example.test").is_none()); + assert!(clean_url("not a url").is_none()); + assert_eq!( + clean_url("HTTPS://api.Example.test"), + Some("HTTPS://api.Example.test".to_owned()) + ); + assert_eq!( + clean_url("http://localhost:8080/api/"), + Some("http://localhost:8080/api".to_owned()) + ); +} + +#[test] +fn env_prefix_uppercases_and_replaces_hyphen() { + assert_eq!(env_prefix("ote"), "OTE"); + assert_eq!(env_prefix("prod-us"), "PROD_US"); +} + +#[test] +fn resolve_default_environments_falls_back_to_default_env_for_an_unresolvable_gdenv_value() { + // A corrupted/stale `.gdenv` value that resolves to nothing (no + // compiled/file entry) must not become the CLI's real startup + // default. + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let envs = resolve_default_environments("totally-bogus-env-name"); + assert_eq!(envs.default_env(), DEFAULT_ENV); + assert!(envs.source(DEFAULT_ENV).is_ok()); +} + +#[test] +fn source_existing_but_resolve_failing_is_the_case_resolve_default_environments_must_catch() { + // `resolve_default_environments`'s own validity check must use + // `.resolve::()`, not `.source()` — a name can be + // *known* to a layer (so `.source()` succeeds) while still missing + // required fields (so `.resolve()` fails). Checking only `.source()` + // would let a misconfigured persisted default stick, instead of + // falling back to `DEFAULT_ENV`. + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // A file-path override pointing at a nonexistent path, not a bare + // `register(...)` — without it this would pick up a real developer's + // own `~/.config/gddy/environments.toml` `[dev]` entry (if any), + // making the test's result depend on that machine's local config. + let dir = tempfile::tempdir().expect("tempdir"); + let missing_file = dir.path().join("environments.toml"); + let probe = register(Environments::new("dev").with_config_file_path_override(missing_file)) + .with_environment( + "dev", + EnvTable::new().with("api_url", "https://api.example.test"), // no client_id + ); + assert!(probe.source("dev").is_ok(), "the name is known"); + assert!( + probe.resolve::("dev").is_err(), + "but it's missing client_id, so it can't actually assemble" + ); +} + +#[test] +fn resolve_default_environments_keeps_a_resolvable_gdenv_value() { + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let envs = resolve_default_environments("prod"); + assert_eq!(envs.default_env(), "prod"); +} + +#[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_per_environment_override_wins_over_global() { + assert_eq!( + devx_core_url_with("dev", |key| match key { + "DEV_DEVX_CORE_URL" => Some("https://dev-core.example.test/".to_owned()), + "DEVX_CORE_URL" => Some("https://shared-core.example.test".to_owned()), + _ => None, + }) + .as_deref(), + Some("https://dev-core.example.test") + ); +} + +#[test] +fn devx_core_url_custom_env_requires_override() { + assert_eq!(devx_core_url_with("dev", |_| None), None); +} diff --git a/rust/src/error.rs b/rust/src/error.rs index 7b881ab5..288c7891 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -46,12 +46,14 @@ mod fixes { /// Live `api call` 404: the requested URL/resource was not found (not a catalog miss). pub(super) const NOT_FOUND_API: &str = "Check the request path and parameters. Inspect the response body for details."; + pub(super) const NOT_FOUND_EMAIL: &str = "Use: gddy email list"; } fn not_found_fix_for(system: &str) -> &'static str { match system { "hosting" => fixes::NOT_FOUND_HOSTING, "api" => fixes::NOT_FOUND_API, + "email" => fixes::NOT_FOUND_EMAIL, // applications / unknown → platform discovery (default NOT_FOUND fix) _ => fixes::NOT_FOUND, } @@ -332,6 +334,15 @@ mod tests { api_missing.error_fix() ); + let email_missing = GddyError::from_http(404, "gone", "email"); + assert!( + email_missing + .error_fix() + .is_some_and(|f| f.contains("email list")), + "{:?}", + email_missing.error_fix() + ); + let client = GddyError::from_http(422, "bad", "applications"); assert_eq!(client.error_code(), codes::NETWORK_ERROR); assert!( diff --git a/rust/src/main.rs b/rust/src/main.rs index df23c744..f5954588 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -6,6 +6,7 @@ mod config; mod contacts; mod dns; mod domain; +mod email; mod env; mod environments; mod error; @@ -39,6 +40,7 @@ pub(crate) fn all_modules() -> Vec { api_explorer::module(), dns::module(), domain::module(), + email::module(), env::module(), hosting::module(), pat::module(), @@ -124,6 +126,13 @@ mod tests { "hosting should stay hidden at the Ga default: {}", output.rendered ); + + let output = cli.run(["gddy", "email", "--help"]).await; + assert_ne!( + output.exit_code, 0, + "email should stay hidden at the Ga default: {}", + output.rendered + ); } #[tokio::test] @@ -166,6 +175,13 @@ mod tests { "hosting should be revealed under an Experimental-min_stage environment: {}", output.rendered ); + + let output = cli.run(["gddy", "email", "--help"]).await; + assert_eq!( + output.exit_code, 0, + "email should be revealed under an Experimental-min_stage environment: {}", + output.rendered + ); } #[tokio::test] diff --git a/rust/src/scopes.rs b/rust/src/scopes.rs index 6b851e03..847bd3f2 100644 --- a/rust/src/scopes.rs +++ b/rust/src/scopes.rs @@ -121,6 +121,11 @@ declare_scopes! { HOSTING_SECRETS_WRITE => "hosting.paas.secrets:write", /// Read Node.js Hosting app logs (`hosting nodejs app logs`). HOSTING_LOGS_READ => "hosting.paas.logs:read", + + /// Read mailboxes and check mailbox-creation eligibility (`email list`). + EMAIL_READ => "email.mailbox:read", + /// Create a mailbox (`email create`). + EMAIL_CREATE => "email.mailbox:create", } /// A requestable scope, its human description, and whether it is requested at @@ -220,6 +225,16 @@ pub const SCOPE_REGISTRY: &[ScopeInfo] = &[ description: "Connect GitHub and import code for your Node.js Hosting apps", default: false, }, + ScopeInfo { + scope: EMAIL_READ, + description: "Read mailboxes and check mailbox-creation eligibility", + default: false, + }, + ScopeInfo { + scope: EMAIL_CREATE, + description: "Create a mailbox", + default: false, + }, ScopeInfo { scope: OFFLINE_ACCESS, description: "Request a refresh token",