diff --git a/src/bl/apps.rs b/src/bl/apps.rs index 96212d8..8d96ac9 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -68,7 +68,7 @@ const TRUSTED_CONTROL_PLANE_HOSTS: &[&str] = &[ ]; pub fn command() -> Command { - let about = "Manage apps through the BuilderLab Apps Platform."; + let about = "Manage apps through the BuilderLab Apps Platform. Use `bl apps handoff` to inspect the audience and open Share & manage for community sharing."; Command::new("apps") .about("Manage apps through Apps Platform") .long_about(about) @@ -467,38 +467,6 @@ pub fn command() -> Command { ), )), ) - .subcommand( - Command::new("share") - .about("Share a restricted app with a BuilderLab workspace") - .long_about( - "Grant or revoke viewing access for current members of a non-personal BuilderLab workspace. \ - The app owner must belong to the target workspace to grant access, but can revoke after leaving it. \ - Use `bl apps access get` to see current grants.", - ) - .subcommand_required(true) - .arg_required_else_help(true) - .disable_help_subcommand(true) - .subcommand(share_action_command("grant")) - .subcommand(share_action_command("revoke")), - ) -} - -fn share_action_command(action: &'static str) -> Command { - control_plane_args( - Command::new(action) - .about(if action == "grant" { - "Let current members of a workspace view an app" - } else { - "Remove a workspace's viewing access to an app" - }) - .arg(Arg::new("app-id").value_name("APP_ID").required(true)) - .arg( - Arg::new("workspace-id") - .value_name("WORKSPACE_ID") - .required(true) - .help("BuilderLab workspace identifier"), - ), - ) } fn control_plane_args(command: Command) -> Command { @@ -550,7 +518,6 @@ fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { Some(("ready", ready_matches)) => run_ready(config, ready_matches), Some(("debug", debug_matches)) => run_debug(config, debug_matches), Some(("access", access_matches)) => run_access(config, access_matches), - Some(("share", share_matches)) => run_share(config, share_matches), _ => anyhow::bail!("expected an apps subcommand"), } } @@ -641,7 +608,9 @@ fn build_app_handoff( .map(|error| handoff_read_failure_reason(error)); let verified = access.ok().filter(|policy| { let string_array = |field| { - policy.get(field).and_then(Value::as_array) + policy + .get(field) + .and_then(Value::as_array) .is_some_and(|values| values.iter().all(Value::is_string)) }; policy.get("ok") == Some(&Value::Bool(true)) @@ -650,14 +619,12 @@ fn build_app_handoff( policy.get("visibility").and_then(Value::as_str), Some("organization" | "restricted") ) - // Compose omits empty grant arrays. Both non-optional revisions - // establish that this response reports workspace AND community grants. - && ["workspace_grant_revision", "community_grant_revision"].iter() - .all(|field| policy.get(field).and_then(Value::as_u64).is_some()) + // Compose omits an empty community list but always reports its revision. + && policy.get("community_grant_revision").and_then(Value::as_u64).is_some() && string_array("viewers") && (policy.get("visibility").and_then(Value::as_str) != Some("restricted") || string_array("effective_viewers")) - && ["shared_workspace_ids", "shared_community_ids", "effective_viewers", "publishers"].iter() + && ["shared_community_ids", "effective_viewers", "publishers"].iter() .all(|field| policy.get(field).is_none() || string_array(field)) }); let reason = verified @@ -1149,44 +1116,6 @@ fn run_access(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { } } -fn run_share(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { - let (action, action_matches) = matches - .subcommand() - .context("expected share grant or revoke")?; - let app_id = action_matches - .get_one::("app-id") - .context("expected app id")?; - let workspace_id = action_matches - .get_one::("workspace-id") - .context("expected workspace id")?; - let (client, credential) = control_plane_context(config, action_matches)?; - // Sharing credentials bind the owner, app and target independently of the - // ordinary credential's selected workspace. Read only the CAS inputs on - // that same route. Kgoose checks target eligibility, and Compose rechecks - // ownership, restricted visibility and revision when applying the grant. - let current = client.get_workspace_grant_revision(&credential, app_id, workspace_id)?; - let lifecycle_id = current - .get("lifecycle_id") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .context("app has no active sharing lifecycle") - .map_err(|error| correlate_response_error(error, ¤t))?; - let expected_revision = current - .get("workspace_grant_revision") - .and_then(Value::as_u64) - .context("app sharing response has no workspace grant revision") - .map_err(|error| correlate_response_error(error, ¤t))?; - let response = client.update_workspace_grant( - &credential, - app_id, - workspace_id, - lifecycle_id, - action, - expected_revision, - )?; - print_json(&response) -} - fn run_access_get(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { let app_id = matches .get_one::("app-id") @@ -1826,58 +1755,6 @@ impl ControlPlaneClient { }) } - fn update_workspace_grant( - &self, - credential: &ComposeSessionCredential, - app_id: &str, - workspace_id: &str, - lifecycle_id: &str, - action: &str, - expected_revision: u64, - ) -> Result { - let url = self.workspace_grant_url(app_id, workspace_id)?; - let path = request_path(&url); - let method = match action { - "grant" => "POST", - "revoke" => "DELETE", - _ => anyhow::bail!("unsupported workspace sharing action"), - }; - let body = json!({ - "lifecycle_id": lifecycle_id, - "expected_revision": expected_revision, - }); - self.authorized_json_request(credential, method, &path, |authorization| { - let request = if method == "POST" { - self.client.post(url.clone()) - } else { - self.client.delete(url.clone()) - }; - self.standard_request(request, authorization) - .json(&body) - .build() - .context("build Apps Platform workspace sharing request") - }) - } - - fn get_workspace_grant_revision( - &self, - credential: &ComposeSessionCredential, - app_id: &str, - workspace_id: &str, - ) -> Result { - self.get_url(credential, self.workspace_grant_url(app_id, workspace_id)?) - } - - fn workspace_grant_url(&self, app_id: &str, workspace_id: &str) -> Result { - let mut url = self.app_url(app_id, &[])?; - url.path_segments_mut() - .map_err(|_| { - anyhow::anyhow!("Apps Platform control-plane URL cannot contain path segments") - })? - .extend(["sharing", "workspaces", workspace_id]); - Ok(url) - } - fn get_app_resource( &self, credential: &ComposeSessionCredential, @@ -2332,6 +2209,13 @@ fn sanitize_recovery_field(value: &Value, credential: &ComposeSessionCredential) Some(value) } +fn is_app_scoped_path(path: &str) -> bool { + let path = path.split('?').next().unwrap_or(path); + path.strip_prefix("/v1/agent/apps/") + .and_then(|rest| rest.split('/').next()) + .is_some_and(|app_id| !app_id.is_empty() && app_id != "plan") +} + fn control_plane_http_failure( method: &str, path: &str, @@ -2388,9 +2272,16 @@ fn control_plane_http_failure( .or_else(|| value.pointer("/error/next_action")) }) .and_then(|value| sanitize_recovery_field(value, credential)) - .or_else(|| (status == StatusCode::FORBIDDEN).then(|| json!( - "Check Apps access for this account and environment, and your workspace and app permissions. Use `bl workspace list` and `bl workspace switch` to check the active workspace." - ))) + .or_else(|| { + (status == StatusCode::FORBIDDEN).then(|| { + // Only a request about one app has an owner to ask. + json!(if is_app_scoped_path(path) { + "Check Apps access for this account and environment, and your workspace and app permissions. Ask the app owner to confirm your viewing or publishing access." + } else { + "Check Apps access for this account and environment, and your workspace permissions." + }) + }) + }) }; let mut message = format!("{method} {path} failed with {status} ({code})"); if let Some((diagnostic, guidance)) = validation { @@ -2607,10 +2498,10 @@ mod tests { #[test] fn handoff_reports_verified_audience_and_exact_management_destination() { let portal = management_portal_url("https://app.builderlabstaging.xyz").unwrap(); - let inspected = json!({"app":{"app_id":"reading-list","workspace_id":"workspace-personal-owner","host":"reading-list.apps.example"}}); - let access = json!({"ok":true,"app_id":"reading-list","visibility":"restricted","viewers":["viewer"],"effective_viewers":["owner","publisher","viewer"],"publishers":["publisher"],"shared_workspace_ids":["team"],"workspace_grant_revision":2,"shared_community_ids":["community"],"community_grant_revision":3}); + let inspected = json!({"app":{"app_id":"reading-list","workspace_id":"workspace-owner","host":"reading-list.apps.example"}}); + let access = json!({"ok":true,"app_id":"reading-list","visibility":"restricted","viewers":["viewer"],"effective_viewers":["owner","publisher","viewer"],"publishers":["publisher"],"shared_community_ids":["community"],"community_grant_revision":3}); let result = build_app_handoff("reading-list", &portal, &inspected, Ok(&access)).unwrap(); - assert_eq!(result["management_url"], "https://app.builderlabstaging.xyz/hosted-apps?app=reading-list&workspace=workspace-personal-owner"); + assert_eq!(result["management_url"], "https://app.builderlabstaging.xyz/hosted-apps?app=reading-list&workspace=workspace-owner"); assert_eq!(result["app_url"], "https://reading-list.apps.example/"); assert_eq!(result["audience"]["status"], "verified"); assert_eq!(result["audience"]["policy"], access); @@ -2619,19 +2510,19 @@ mod tests { } #[test] - fn handoff_requires_complete_workspace_and_community_grant_reporting() { + fn handoff_requires_complete_community_grant_reporting() { let portal = management_portal_url("https://app.builderlab.xyz").unwrap(); let app = json!({"app":{"app_id":"demo","workspace_id":"team"}}); for visibility in ["restricted", "organization"] { - // Compose omits empty grant arrays, but always reports both revisions. + // No legacy workspace-grant fields are needed to confirm the audience. let complete = json!({"ok":true,"app_id":"demo","visibility":visibility, "viewers":[],"effective_viewers":["owner"], - "workspace_grant_revision":0,"community_grant_revision":0}); + "community_grant_revision":0}); let result = build_app_handoff("demo", &portal, &app, Ok(&complete)).unwrap(); assert_eq!(result["audience"]["status"], "verified"); assert_eq!(result["audience"]["policy"], complete); - for field in ["workspace_grant_revision", "community_grant_revision"] { + for field in ["community_grant_revision"] { for invalid in [ None, Some(Value::Null), @@ -2653,7 +2544,6 @@ mod tests { } } for field in [ - "shared_workspace_ids", "shared_community_ids", "viewers", "effective_viewers", @@ -3015,6 +2905,50 @@ mod tests { assert_eq!(requests[0].body, Value::Null); } + #[test] + fn app_scoped_paths_are_requests_about_one_app() { + for path in [ + "/v1/agent/apps/demo", + "/v1/agent/apps/demo?environment=staging", + "/v1/agent/apps/demo/access", + "/v1/agent/apps/merchant%2Flookup%20app/deploy", + ] { + assert!(is_app_scoped_path(path), "{path}"); + } + for path in [ + APPS_CONTRACT_PATH, + APPS_PLAN_PATH, + "/v1/agent/apps", + "/v1/agent/apps?scope=owned", + "/v1/agent/apps/", + ] { + assert!(!is_app_scoped_path(path), "{path}"); + } + } + + #[test] + fn bl_apps_generic_denial_asks_the_owner_only_for_an_app_request() { + let credential = "apps-e2e-only.app-denial.session"; + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ + ProcessResponse::raw(403, "").with_trace_id(REQUEST_TRACE_ID) + ]); + let output = process_command( + &auth_server, + &control_plane, + &["apps", "get", "demo", "--base-url", APPROVED_TEST_BASE_URL], + credential, + ) + .output() + .expect("run app denial"); + assert_eq!(output.status.code(), Some(exit_codes::FORBIDDEN)); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Ask the app owner to confirm"), "{stderr}"); + assert!(!stderr.contains("bl workspace"), "{stderr}"); + auth_server.finish(); + assert_eq!(control_plane.finish()[0].path, "/v1/agent/apps/demo"); + } + #[test] fn bl_apps_process_distinguishes_account_setup_from_other_denials() { let credential = "apps-e2e-only.account-access.session"; @@ -3048,28 +2982,28 @@ mod tests { 403, "", "control_plane_request_failed", - "workspace and app permissions", + "and your workspace permissions.", ), ( json!({"account_status": "ACCOUNT_STATUS_REJECTED", "enabled_products": []}), 403, "", "control_plane_request_failed", - "workspace and app permissions", + "and your workspace permissions.", ), ( json!({"account_status": "ACCOUNT_STATUS_FUTURE", "enabled_products": []}), 403, "", "control_plane_request_failed", - "workspace and app permissions", + "and your workspace permissions.", ), ( json!({}), 403, "", "control_plane_request_failed", - "workspace and app permissions", + "and your workspace permissions.", ), ( needs_details.clone(), @@ -3119,6 +3053,10 @@ mod tests { assert!(!stderr.contains(credential)); assert!(!stderr.contains("private-diagnostic")); assert!(!stderr.contains("apps@example.com")); + assert!(!stderr.contains("bl workspace switch")); + assert!(!stderr.contains("bl workspace list")); + // Listing apps has no app owner to ask. + assert!(!stderr.contains("Ask the app owner to confirm"), "{stderr}"); if code != "account_setup_required" { assert!(!stderr.contains("Finish account setup")); } @@ -3260,7 +3198,7 @@ mod tests { for (policy, expected_status, expected_reason) in [ ( ProcessResponse::json( - json!({"ok":true,"app_id":"demo","visibility":"restricted","viewers":[],"effective_viewers":["owner"],"shared_workspace_ids":["team"],"workspace_grant_revision":1,"shared_community_ids":["community"],"community_grant_revision":2}), + json!({"ok":true,"app_id":"demo","visibility":"restricted","viewers":[],"effective_viewers":["owner"],"shared_community_ids":["community"],"community_grant_revision":2}), ), "verified", Value::Null, @@ -3310,7 +3248,7 @@ mod tests { let auth = ProcessServer::start(vec![]); let control = ProcessServer::start(vec![ ProcessResponse::json( - json!({"ok":true,"app":{"app_id":"demo","workspace_id":"owner-personal","host":"demo.apps.example"}}), + json!({"ok":true,"app":{"app_id":"demo","workspace_id":"owning-workspace","host":"demo.apps.example"}}), ), policy, ]); @@ -3339,7 +3277,7 @@ mod tests { let handoff: Value = serde_json::from_str(&process_stdout(&output)).unwrap(); assert_eq!( handoff["management_url"], - "https://app.builderlabstaging.xyz/hosted-apps?app=demo&workspace=owner-personal" + "https://app.builderlabstaging.xyz/hosted-apps?app=demo&workspace=owning-workspace" ); assert_eq!(handoff["recipient_browser_access_verified"], false); assert_eq!(handoff["audience"]["status"], expected_status); @@ -3351,10 +3289,6 @@ mod tests { if expected_status == "unknown" { assert!(handoff["audience"]["policy"].is_null()); } else { - assert_eq!( - handoff["audience"]["policy"]["shared_workspace_ids"], - json!(["team"]) - ); assert_eq!( handoff["audience"]["policy"]["shared_community_ids"], json!(["community"]) @@ -3559,174 +3493,6 @@ mod tests { ); } - #[test] - fn bl_apps_share_grant_uses_owner_scoped_read_without_source_workspace() { - for proxy in [false, true] { - let credential = "apps-e2e-only.share.session"; - let auth_server = ProcessServer::start(if proxy { - vec![] - } else { - vec![process_auth_response()] - }); - // The sharing read deliberately returns only CAS inputs, not the - // ordinary access policy from the owner's source workspace. - let control_plane = ProcessServer::start(vec![ - ProcessResponse::json(json!({ - "ok": true, - "app_id": "my-app", - "lifecycle_id": "life-123", - "workspace_grant_revision": 7, - })), - ProcessResponse::json(json!({"ok": true, "workspace_grant_revision": 8})), - ]); - let mut command = process_command( - &auth_server, - &control_plane, - &["apps", "share", "grant", "my-app", "target-team", "--json"], - credential, - ); - command.env( - APPS_BASE_URL_ENV_VAR, - if proxy { - "http://compose.mesh.example" - } else { - APPROVED_TEST_BASE_URL - }, - ); - if proxy { - command.env("BL_APPS_E2E_PROXY", "true"); - } - let output = command.output().expect("run workspace grant process"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let auth_requests = auth_server.finish(); - assert_eq!(auth_requests.len(), usize::from(!proxy)); - let requests = control_plane.finish(); - assert_eq!(requests.len(), 2); - let path = "/v1/agent/apps/my-app/sharing/workspaces/target-team"; - assert_eq!(requests[0].method, "GET"); - assert_eq!(requests[0].path, path); - assert_eq!(requests[1].method, "POST"); - assert_eq!(requests[1].path, path); - assert_eq!( - requests[1].body, - json!({"lifecycle_id": "life-123", "expected_revision": 7}) - ); - if proxy { - for request in &requests { - for header in ["authorization", "cookie", "x-bb-session-credential"] { - assert!(!request.headers.contains_key(header)); - } - } - } else { - assert_process_auth(&auth_requests[0], credential); - assert_process_control_plane(&requests[0], "GET", path, credential); - assert_process_control_plane(&requests[1], "POST", path, credential); - } - } - } - - #[test] - fn bl_apps_share_grant_preserves_server_denials_and_does_not_retry() { - for (read_denied, status, code) in [ - (true, 403, "workspace_grant_denied"), - (false, 403, "workspace_grant_denied"), - (false, 409, "workspace_grant_conflict"), - ] { - let auth_server = ProcessServer::start(vec![]); - let denied = ProcessResponse::raw(status, json!({"error": {"code": code}}).to_string()) - .with_trace_id(REQUEST_TRACE_ID); - let mut responses = vec![]; - if !read_denied { - responses.push(ProcessResponse::json(json!({ - "ok": true, - "lifecycle_id": "life-123", - "workspace_grant_revision": 7, - }))); - } - responses.push(denied); - let control_plane = ProcessServer::start(responses); - let output = process_command( - &auth_server, - &control_plane, - &["apps", "share", "grant", "my-app", "target-team", "--json"], - "apps-e2e-only.unused", - ) - .env("BL_APPS_E2E_PROXY", "true") - .env(APPS_BASE_URL_ENV_VAR, "http://compose.mesh.example") - .output() - .expect("run denied workspace grant process"); - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains(code), "{stderr}"); - assert!(stderr.contains(REQUEST_TRACE_ID), "{stderr}"); - assert!(auth_server.finish().is_empty()); - let requests = control_plane.finish(); - assert_eq!(requests.len(), if read_denied { 1 } else { 2 }); - let path = "/v1/agent/apps/my-app/sharing/workspaces/target-team"; - assert_eq!(requests[0].method, "GET"); - assert_eq!(requests[0].path, path); - if !read_denied { - assert_eq!(requests[1].method, "POST"); - assert_eq!(requests[1].path, path); - } - } - } - - #[test] - fn bl_apps_share_revoke_reads_only_the_target_grant_before_delete() { - let credential = "apps-e2e-only.revoke.session+credential"; - let auth_server = ProcessServer::start(vec![process_auth_response()]); - let control_plane = ProcessServer::start(vec![ - ProcessResponse::json(json!({ - "ok": true, - "app_id": "my-app", - "lifecycle_id": "life-123", - "workspace_grant_revision": 7, - })), - ProcessResponse::json(json!({"ok": true, "workspace_grant_revision": 8})), - ]); - let mut command = process_command( - &auth_server, - &control_plane, - &[ - "apps", - "share", - "revoke", - "my-app", - "former-team", - "--base-url", - APPROVED_TEST_BASE_URL, - "--client-version", - "0.2.0", - ], - credential, - ); - let output = command - .output() - .expect("run workspace revoke process command"); - assert!( - output.status.success(), - "stderr was: {}", - String::from_utf8_lossy(&output.stderr) - ); - let auth_requests = auth_server.finish(); - assert_eq!(auth_requests.len(), 1); - assert_process_auth(&auth_requests[0], credential); - let requests = control_plane.finish(); - assert_eq!(requests.len(), 2); - let path = "/v1/agent/apps/my-app/sharing/workspaces/former-team"; - assert_process_control_plane(&requests[0], "GET", path, credential); - assert_process_control_plane(&requests[1], "DELETE", path, credential); - assert_eq!( - requests[1].body, - json!({"lifecycle_id":"life-123","expected_revision":7}) - ); - } - #[test] fn bl_apps_access_process_explicitly_clears_restricted_viewers() { let credential = "apps-e2e-only.access.clear.session+credential"; @@ -6162,49 +5928,6 @@ mod tests { } } - #[test] - fn bl_apps_share_process_retains_trace_on_validation_failures() { - for body in [ - json!({}), - json!({"lifecycle_id":"life-123"}), - json!({"lifecycle_id":"life-123", "workspace_grant_revision":-1}), - ] { - let auth_server = ProcessServer::start(vec![process_auth_response()]); - let control_plane = ProcessServer::start(vec![ - ProcessResponse::json(body).with_trace_id(REQUEST_TRACE_ID) - ]); - let output = process_command( - &auth_server, - &control_plane, - &[ - "apps", - "share", - "grant", - "app", - "test-workspace", - "--base-url", - APPROVED_TEST_BASE_URL, - "--client-version", - "0.2.0", - "--json", - ], - "apps-e2e-only.trace-share.session", - ) - .output() - .expect("run invalid share command"); - assert_eq!( - process_error(&output)["error"]["trace_id"], - REQUEST_TRACE_ID - ); - auth_server.finish(); - assert_eq!( - control_plane.finish().len(), - 1, - "never mutate invalid sharing state" - ); - } - } - #[test] fn delete_retains_response_id_through_http_and_unknown_outcome_failures() { for (status, body, expected_code, expected_exit) in [ @@ -7451,75 +7174,41 @@ mod tests { } #[test] - fn workspace_grant_uses_target_path_and_lifecycle_revision() { - let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); - let base_url = format!("http://{}", server.server_addr()); - let server_thread = thread::spawn(move || { - for method in ["GET", "POST", "DELETE"] { - let mut request = server.recv().expect("receive sharing request"); - assert_eq!(request.method().as_str(), method); - assert_eq!( - request.url(), - "/v1/agent/apps/my-app/sharing/workspaces/team-workspace" - ); - let mut body = String::new(); - request - .as_reader() - .read_to_string(&mut body) - .expect("read body"); - if method == "GET" { - assert!(body.is_empty()); - } else { - assert_eq!( - serde_json::from_str::(&body).expect("parse body"), - json!({"lifecycle_id":"life-123","expected_revision":if method == "POST" { 4 } else { 5 }}) - ); - } - request - .respond( - Response::from_string(if method == "GET" { - r#"{"ok":true,"lifecycle_id":"life-123","workspace_grant_revision":5}"# - } else { - r#"{"ok":true}"# - }) - .with_header( - Header::from_bytes("Content-Type", "application/json") - .expect("build content type"), - ), - ) - .expect("respond to sharing request"); - } - }); - let client = test_control_plane_client(&base_url, Duration::from_secs(2)); - let credential = test_credential("sharing_session_credential_123456789012345"); - let current = client - .get_workspace_grant_revision(&credential, "my-app", "team-workspace") - .expect("read sharing revision without deployment workspace access"); - assert_eq!(current["lifecycle_id"], "life-123"); - assert_eq!(current["workspace_grant_revision"], 5); - let response = client - .update_workspace_grant( - &credential, - "my-app", - "team-workspace", - "life-123", - "grant", - 4, - ) - .expect("update workspace grant"); - assert_eq!(response["ok"], true); - let response = client - .update_workspace_grant( - &credential, - "my-app", - "team-workspace", - "life-123", - "revoke", - 5, + fn workspace_sharing_is_absent_from_help_and_command_metadata() { + let mut apps = command(); + assert!(apps.find_subcommand("share").is_none()); + let help = apps.render_long_help().to_string(); + assert!(!help.contains("Share a restricted app with a BuilderLab workspace")); + let described = describe_commands(); + assert!(!described.to_string().contains("WORKSPACE_ID")); + } + + #[test] + fn retired_workspace_sharing_never_authenticates_or_reaches_compose() { + for action in ["grant", "revoke"] { + let auth_server = ProcessServer::start(vec![]); + let control_plane = ProcessServer::start(vec![]); + let output = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "share", + action, + "my-app", + "target-workspace", + "--json", + ], + "apps-e2e-only.retired-share.session", ) - .expect("revoke workspace grant"); - assert_eq!(response["ok"], true); - server_thread.join().expect("join control-plane server"); + .output() + .expect("run retired sharing command"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("share"), "{stderr}"); + assert!(auth_server.finish().is_empty()); + assert!(control_plane.finish().is_empty()); + } } #[test]