From 912ffea4ae42d84b893b7951084287cf1e0bc0a3 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 11 Sep 2026 12:03:33 -0300 Subject: [PATCH 1/4] fix(auth): skip authentication for request dry-runs --- .changeset/offline-dry-run.md | 9 + README.md | 15 + .../google-workspace-cli/src/helpers/docs.rs | 15 +- crates/google-workspace-cli/src/main.rs | 31 +- crates/google-workspace-cli/tests/dry_run.rs | 533 ++++++++++++++++++ 5 files changed, 585 insertions(+), 18 deletions(-) create mode 100644 .changeset/offline-dry-run.md create mode 100644 crates/google-workspace-cli/tests/dry_run.rs diff --git a/.changeset/offline-dry-run.md b/.changeset/offline-dry-run.md new file mode 100644 index 000000000..ca86d5669 --- /dev/null +++ b/.changeset/offline-dry-run.md @@ -0,0 +1,9 @@ +--- +"@googleworkspace/cli": patch +--- + +Skip authentication for Discovery-generated API and `docs +write` dry-runs. +Validate and preview requests without accessing the keyring or reading, changing, +or deleting stored credentials and token caches. Dry-runs work offline with a +fresh cached Discovery schema; schema fetching on first use or cache expiry is +unchanged. Real requests retain their existing authentication and error handling. diff --git a/README.md b/README.md index 04c532d0a..81f8da031 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,21 @@ gws schema drive.files.list gws drive files list --params '{"pageSize": 100}' --page-all | jq -r '.files[].name' ``` +Discovery-generated API commands and `gws docs +write` support credential-free +`--dry-run`: they validate inputs and display the request without obtaining a +token, accessing the keyring, reading or changing stored credentials, or sending +the API request. + +```bash +# Preview a Docs append without signing in +gws docs +write --document DOC_ID --text 'Hello, world!' --dry-run +``` + +These previews work offline with a fresh cached Discovery schema (24-hour TTL). +First use or an expired cache can still fetch the schema over the network. +Other helpers may need authenticated reads to prepare their plans; this guarantee +applies to raw API commands and `docs +write`. + ## Authentication The CLI supports multiple auth workflows so it works on your laptop, in CI, and on a server. diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index d3ef7fa21..05a0ff6f1 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -70,10 +70,15 @@ TIPS: let (params_str, body_str, scopes) = build_write_request(matches, doc)?; let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), - Err(e) => return Err(GwsError::Auth(format!("Docs auth failed: {e}"))), + let dry_run = matches.get_flag("dry-run"); + // Skip auth entirely: even failed auth can mutate stored credentials. + let (token, auth_method) = if dry_run { + (None, executor::AuthMethod::None) + } else { + match auth::get_token(&scope_strs).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(e) => return Err(GwsError::Auth(format!("Docs auth failed: {e}"))), + } }; // Method: documents.batchUpdate @@ -100,7 +105,7 @@ TIPS: auth_method, None, None, - matches.get_flag("dry-run"), + dry_run, &pagination, None, &crate::helpers::modelarmor::SanitizeMode::Warn, diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 41dcc1e1f..6f49b9cea 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -261,19 +261,24 @@ async fn run() -> Result<(), GwsError> { // to avoid restrictive scopes like gmail.metadata that block query parameters. let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect(); - // Authenticate: try OAuth, fail with error if credentials exist but are broken - let (token, auth_method) = match auth::get_token(&scopes).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(e) => { - // If credentials were found but failed (e.g. decryption error, invalid token), - // propagate the error instead of silently falling back to unauthenticated. - // Only fall back to None if no credentials exist at all. - let err_msg = format!("{e:#}"); - // NB: matches the bail!() message in auth::load_credentials_inner - if err_msg.starts_with("No credentials found") { - (None, executor::AuthMethod::None) - } else { - return Err(GwsError::Auth(format!("Authentication failed: {err_msg}"))); + // Dry-runs only need the schema and inputs. Do not load credentials: + // authentication may access the keyring or remove corrupt credential files. + let (token, auth_method) = if dry_run { + (None, executor::AuthMethod::None) + } else { + match auth::get_token(&scopes).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(e) => { + // If credentials were found but failed (e.g. decryption error, invalid token), + // propagate the error instead of silently falling back to unauthenticated. + // Only fall back to None if no credentials exist at all. + let err_msg = format!("{e:#}"); + // NB: matches the bail!() message in auth::load_credentials_inner + if err_msg.starts_with("No credentials found") { + (None, executor::AuthMethod::None) + } else { + return Err(GwsError::Auth(format!("Authentication failed: {err_msg}"))); + } } } }; diff --git a/crates/google-workspace-cli/tests/dry_run.rs b/crates/google-workspace-cli/tests/dry_run.rs new file mode 100644 index 000000000..3f03a3496 --- /dev/null +++ b/crates/google-workspace-cli/tests/dry_run.rs @@ -0,0 +1,533 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; +use tempfile::TempDir; + +const BODY: &str = + r#"{"requests":[{"insertText":{"text":"hello","endOfSegmentLocation":{"segmentId":""}}}]}"#; +const RAW: &[&str] = &[ + "docs", + "documents", + "batchUpdate", + "--params", + r#"{"documentId":"doc /?#","fields":"documentId"}"#, + "--json", + BODY, +]; +const WRITE: &[&str] = &["docs", "+write", "--document", "doc /?#", "--text", "hello"]; + +// Observe every API/proxy connection without contacting Google. Returning an +// error also lets real-request tests verify that API failures stay failures. +struct NetworkTrap { + url: String, + requests: Arc>>, + stop: mpsc::Sender<()>, + worker: Option>, +} + +impl NetworkTrap { + fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let url = format!("http://{}/", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let (stop, stopping) = mpsc::channel(); + let worker = thread::spawn(move || loop { + match listener.accept() { + Ok((mut stream, _)) => { + // Record the connection even if the client fails before + // sending HTTP (for example, during TLS/proxy setup). + let mut requests = captured.lock().unwrap(); + requests.push(String::new()); + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let mut header = Vec::new(); + let mut buffer = [0; 1024]; + while header.len() < 8192 && !header.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(size) => header.extend_from_slice(&buffer[..size]), + } + } + *requests.last_mut().unwrap() = String::from_utf8_lossy(&header).into_owned(); + let body = r#"{"error":{"code":403,"message":"Synthetic denial","errors":[{"reason":"forbidden"}]}}"#; + let _ = write!( + stream, + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if stopping.recv_timeout(Duration::from_millis(5)) + != Err(mpsc::RecvTimeoutError::Timeout) + { + break; + } + } + Err(error) => panic!("Network trap failed: {error}"), + } + }); + Self { + url, + requests, + stop, + worker: Some(worker), + } + } +} + +impl Drop for NetworkTrap { + fn drop(&mut self) { + let _ = self.stop.send(()); + self.worker.take().unwrap().join().unwrap(); + } +} + +type Snapshot = BTreeMap, SystemTime)>; + +fn snapshot(root: &Path) -> Snapshot { + let mut files = BTreeMap::new(); + for entry in fs::read_dir(root).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + files.extend(snapshot(&path)); + } else { + files.insert( + path.clone(), + ( + fs::read(&path).unwrap(), + fs::metadata(path).unwrap().modified().unwrap(), + ), + ); + } + } + files +} + +struct Fixture { + dir: TempDir, + config: PathBuf, + network: NetworkTrap, +} + +impl Fixture { + fn new(corrupt_credentials: bool) -> Self { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config"); + fs::create_dir_all(config.join("cache")).unwrap(); + // Stop dotenvy's ancestor search and isolate all credential sources. + fs::write(dir.path().join(".env"), "").unwrap(); + fs::create_dir(dir.path().join("home")).unwrap(); + let fixture = Self { + dir, + config, + network: NetworkTrap::new(), + }; + fixture.write_discovery(&fixture.discovery()); + if corrupt_credentials { + for name in [ + "credentials.enc", + "credentials.json", + ".encryption_key", + "token_cache.json", + "sa_token_cache.json", + ] { + fs::write( + fixture.config.join(name), + format!("corrupt synthetic sentinel: {name}"), + ) + .unwrap(); + } + } + fixture + } + + fn discovery(&self) -> Value { + json!({ + "name": "docs", + "version": "v1", + "rootUrl": self.network.url, + "servicePath": "v1/", + "resources": { + "documents": { + "methods": { + "batchUpdate": { + "id": "docs.documents.batchUpdate", + "httpMethod": "POST", + "path": "documents/{documentId}:batchUpdate", + "parameterOrder": ["documentId"], + "parameters": { + "documentId": {"type": "string", "location": "path", "required": true}, + "fields": {"type": "string", "location": "query"} + }, + "request": {"$ref": "BatchUpdateDocumentRequest"}, + "scopes": ["https://www.googleapis.com/auth/documents"] + } + } + } + }, + "schemas": { + "BatchUpdateDocumentRequest": { + "type": "object", + "required": ["requests"], + "properties": { + "requests": {"type": "array", "items": {"$ref": "Request"}} + } + }, + "Request": { + "type": "object", + "properties": { + "insertText": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "endOfSegmentLocation": { + "type": "object", + "properties": {"segmentId": {"type": "string"}} + } + } + } + } + } + } + }) + } + + fn write_discovery(&self, discovery: &Value) { + // Exercise the real config override, cache filename and freshness check. + fs::write( + self.config.join("cache/docs_v1.json"), + serde_json::to_vec(discovery).unwrap(), + ) + .unwrap(); + } + + fn run(&self, args: &[&str], token: Option<&str>) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_gws")); + command + .args(args) + .current_dir(self.dir.path()) + .env_clear() + .env("HOME", self.dir.path().join("home")) + .env("USERPROFILE", self.dir.path().join("home")) + .env("APPDATA", self.dir.path().join("home")) + .env("XDG_CONFIG_HOME", self.dir.path().join("home")) + .env("USER", "gws-dry-run-test") + .env("USERNAME", "gws-dry-run-test") + .env("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", &self.config) + // Never query an actual OS account, even when testing broken auth. + .env("GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND", "file") + .env("HTTP_PROXY", &self.network.url) + .env("HTTPS_PROXY", &self.network.url) + .env("ALL_PROXY", &self.network.url) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(system_root) = std::env::var_os("SystemRoot") { + command.env("SystemRoot", system_root); + } + if let Some(token) = token { + command.env("GOOGLE_WORKSPACE_CLI_TOKEN", token); + } + let mut child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(15); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!("CLI timed out: {output:?}"); + } + thread::sleep(Duration::from_millis(10)); + } + child.wait_with_output().unwrap() + } + + fn dry_run(&self, args: &[&str], exit_code: i32) -> Value { + let before = snapshot(self.dir.path()); + let mut args = args.to_vec(); + args.push("--dry-run"); + let output = self.run(&args, None); + assert_eq!( + output.status.code(), + Some(exit_code), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let after = snapshot(self.dir.path()); + assert_eq!( + after.keys().collect::>(), + before.keys().collect::>(), + "Dry-run created or deleted fixture files" + ); + for (path, expected) in &before { + assert!( + after.get(path) == Some(expected), + "Dry-run changed contents or mtime: {path:?}" + ); + } + assert!( + self.network.requests.lock().unwrap().is_empty(), + "Dry-run attempted an API, auth or Discovery connection" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("keyring") && !stderr.contains("credentials"), + "Dry-run unexpectedly used auth: {stderr}" + ); + serde_json::from_slice(&output.stdout).unwrap() + } + + fn assert_preview(&self, preview: &Value, query: Value) { + assert_eq!( + preview, + &json!({ + "dry_run": true, + "url": format!("{}v1/documents/doc%20%2F%3F%23:batchUpdate", self.network.url), + "method": "POST", + "query_params": query, + "body": { + "requests": [{ + "insertText": { + "text": "hello", + "endOfSegmentLocation": {"segmentId": ""} + } + }] + }, + "is_multipart_upload": false + }) + ); + } +} + +#[test] +fn raw_dry_run_preserves_corrupt_credentials() { + let fixture = Fixture::new(true); + fixture.assert_preview(&fixture.dry_run(RAW, 0), json!([["fields", "documentId"]])); +} + +#[test] +fn raw_dry_run_needs_no_credentials() { + let fixture = Fixture::new(false); + fixture.assert_preview(&fixture.dry_run(RAW, 0), json!([["fields", "documentId"]])); +} + +#[test] +fn docs_write_dry_run_preserves_corrupt_credentials() { + let fixture = Fixture::new(true); + fixture.assert_preview(&fixture.dry_run(WRITE, 0), json!([])); +} + +#[test] +fn docs_write_dry_run_needs_no_credentials() { + let fixture = Fixture::new(false); + fixture.assert_preview(&fixture.dry_run(WRITE, 0), json!([])); +} + +fn assert_validation(error: &Value, message: &str) { + assert_eq!(error["error"]["reason"], "validationError"); + assert!( + error["error"]["message"] + .as_str() + .unwrap() + .contains(message), + "{error}" + ); +} + +#[test] +fn raw_dry_run_rejects_malformed_body_without_auth() { + let fixture = Fixture::new(true); + let mut args = RAW.to_vec(); + *args.last_mut().unwrap() = "{"; + assert_validation(&fixture.dry_run(&args, 3), "Invalid --json body"); +} + +#[test] +fn raw_dry_run_validates_nested_body_without_auth() { + let fixture = Fixture::new(true); + let mut args = RAW.to_vec(); + *args.last_mut().unwrap() = r#"{"requests":[{"insertText":{"text":42}}]}"#; + assert_validation(&fixture.dry_run(&args, 3), "Expected type 'string'"); +} + +#[test] +fn raw_dry_run_rejects_malformed_params_without_auth() { + let fixture = Fixture::new(true); + let mut args = RAW.to_vec(); + args[4] = "{"; + assert_validation(&fixture.dry_run(&args, 3), "Invalid --params JSON"); +} + +#[test] +fn raw_dry_run_requires_path_parameter_without_auth() { + let fixture = Fixture::new(true); + let mut args = RAW.to_vec(); + args[4] = "{}"; + assert_validation(&fixture.dry_run(&args, 3), "documentId is missing"); +} + +#[test] +fn raw_dry_run_requires_query_parameter_without_auth() { + let fixture = Fixture::new(true); + let mut doc = fixture.discovery(); + doc["resources"]["documents"]["methods"]["batchUpdate"]["parameters"]["revision"] = + json!({"type": "string", "location": "query", "required": true}); + fixture.write_discovery(&doc); + assert_validation(&fixture.dry_run(RAW, 3), "'revision' is missing"); +} + +#[test] +fn raw_dry_run_rejects_resource_traversal_without_auth() { + let fixture = Fixture::new(true); + let mut doc = fixture.discovery(); + doc["resources"]["documents"]["methods"]["batchUpdate"]["path"] = + json!("documents/{+documentId}:batchUpdate"); + fixture.write_discovery(&doc); + let mut args = RAW.to_vec(); + args[4] = r#"{"documentId":"../outside"}"#; + assert_validation(&fixture.dry_run(&args, 3), "traversal"); +} + +#[test] +fn raw_dry_run_rejects_output_traversal_without_auth() { + let fixture = Fixture::new(true); + let mut args = RAW.to_vec(); + args.extend(["--output", "../outside"]); + assert_validation(&fixture.dry_run(&args, 3), "outside the current directory"); +} + +#[test] +fn docs_write_dry_run_requires_document() { + let fixture = Fixture::new(true); + assert_validation( + &fixture.dry_run(&["docs", "+write", "--text", "hello"], 3), + "--document", + ); +} + +#[test] +fn docs_write_dry_run_requires_text() { + let fixture = Fixture::new(true); + assert_validation( + &fixture.dry_run(&["docs", "+write", "--document", "doc"], 3), + "--text", + ); +} + +#[test] +fn docs_write_dry_run_validates_generated_body_without_auth() { + let fixture = Fixture::new(true); + let mut doc = fixture.discovery(); + doc["schemas"]["BatchUpdateDocumentRequest"]["required"] = json!(["requests", "title"]); + fixture.write_discovery(&doc); + assert_validation( + &fixture.dry_run(WRITE, 3), + "Missing required property 'title'", + ); +} + +#[test] +fn docs_write_dry_run_preserves_discovery_errors() { + let fixture = Fixture::new(true); + let mut doc = fixture.discovery(); + doc["resources"]["documents"]["methods"] = json!({}); + fixture.write_discovery(&doc); + let error = fixture.dry_run(WRITE, 4); + assert_eq!(error["error"]["reason"], "discoveryError"); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("batchUpdate")); +} + +fn assert_auth_failure(args: &[&str]) { + let fixture = Fixture::new(true); + let output = fixture.run(args, None); + assert_eq!(output.status.code(), Some(2), "{output:?}"); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error"]["reason"], "authError"); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("credentials")); + // Prove this is the real auth path: existing corrupt-credential cleanup + // still runs, and the broken plaintext fallback remains a hard error. + assert!(!fixture.config.join("credentials.enc").exists()); + assert!(!fixture.config.join("token_cache.json").exists()); + assert!(!fixture.config.join("sa_token_cache.json").exists()); + assert!(fixture.network.requests.lock().unwrap().is_empty()); +} + +#[test] +fn raw_real_request_still_fails_on_broken_credentials() { + assert_auth_failure(RAW); +} + +#[test] +fn docs_write_real_request_still_fails_on_broken_credentials() { + assert_auth_failure(WRITE); +} + +#[test] +fn raw_real_request_without_credentials_preserves_access_denied() { + let fixture = Fixture::new(false); + let output = fixture.run(RAW, None); + assert_eq!(output.status.code(), Some(2), "{output:?}"); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error"]["reason"], "authError"); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("No credentials provided")); + let requests = fixture.network.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert!(!requests[0].to_lowercase().contains("authorization:")); +} + +fn assert_authenticated_api_failure(args: &[&str]) { + let fixture = Fixture::new(false); + let output = fixture.run(args, Some("synthetic-test-token")); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error"]["code"], 403); + assert_eq!(error["error"]["message"], "Synthetic denial"); + let requests = fixture.network.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert!(requests[0] + .to_lowercase() + .contains("authorization: bearer synthetic-test-token")); +} + +#[test] +fn raw_real_request_uses_token_and_preserves_api_failure() { + assert_authenticated_api_failure(RAW); +} + +#[test] +fn docs_write_real_request_uses_token_and_preserves_api_failure() { + assert_authenticated_api_failure(WRITE); +} From 54c260421a3cd665c29ec526f0681c9a58c859f1 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 11 Sep 2026 11:59:05 -0300 Subject: [PATCH 2/4] fix(script): keep current Clippy checks passing --- .changeset/current-clippy-baseline.md | 5 +++++ crates/google-workspace-cli/src/helpers/script.rs | 8 +------- 2 files changed, 6 insertions(+), 7 deletions(-) create mode 100644 .changeset/current-clippy-baseline.md diff --git a/.changeset/current-clippy-baseline.md b/.changeset/current-clippy-baseline.md new file mode 100644 index 000000000..a14e86242 --- /dev/null +++ b/.changeset/current-clippy-baseline.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Keep Apps Script file selection compatible with the current Clippy checks. diff --git a/crates/google-workspace-cli/src/helpers/script.rs b/crates/google-workspace-cli/src/helpers/script.rs index 11bcdebec..4b31db62d 100644 --- a/crates/google-workspace-cli/src/helpers/script.rs +++ b/crates/google-workspace-cli/src/helpers/script.rs @@ -169,13 +169,7 @@ fn process_file(path: &Path) -> Result, GwsError> { filename.trim_end_matches(".js").trim_end_matches(".gs"), ), "html" => ("HTML", filename.trim_end_matches(".html")), - "json" => { - if filename == "appsscript.json" { - ("JSON", "appsscript") - } else { - return Ok(None); - } - } + "json" if filename == "appsscript.json" => ("JSON", "appsscript"), _ => return Ok(None), }; From b0d8abdf6b45b7675fe756319364df0c3a0cebe5 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 11 Sep 2026 12:19:40 -0300 Subject: [PATCH 3/4] test(auth): isolate dry-run fixtures from profile credentials --- crates/google-workspace-cli/tests/dry_run.rs | 70 ++++++++++++++++++-- 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/crates/google-workspace-cli/tests/dry_run.rs b/crates/google-workspace-cli/tests/dry_run.rs index 3f03a3496..bcd578e8e 100644 --- a/crates/google-workspace-cli/tests/dry_run.rs +++ b/crates/google-workspace-cli/tests/dry_run.rs @@ -225,7 +225,7 @@ impl Fixture { .unwrap(); } - fn run(&self, args: &[&str], token: Option<&str>) -> Output { + fn command(&self, args: &[&str], token: Option<&str>) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_gws")); command .args(args) @@ -238,6 +238,12 @@ impl Fixture { .env("USER", "gws-dry-run-test") .env("USERNAME", "gws-dry-run-test") .env("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", &self.config) + // Windows known-folder lookup ignores the home overrides above. + // Pin both token loading and quota-project lookup to the fixture. + .env( + "GOOGLE_APPLICATION_CREDENTIALS", + self.dir.path().join("missing-adc.json"), + ) // Never query an actual OS account, even when testing broken auth. .env("GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND", "file") .env("HTTP_PROXY", &self.network.url) @@ -252,6 +258,14 @@ impl Fixture { if let Some(token) = token { command.env("GOOGLE_WORKSPACE_CLI_TOKEN", token); } + command + } + + fn run(&self, args: &[&str], token: Option<&str>) -> Output { + Self::run_command(self.command(args, token)) + } + + fn run_command(mut command: Command) -> Output { let mut child = command.spawn().unwrap(); let deadline = Instant::now() + Duration::from_secs(15); while child.try_wait().unwrap().is_none() { @@ -474,11 +488,6 @@ fn assert_auth_failure(args: &[&str]) { .as_str() .unwrap() .contains("credentials")); - // Prove this is the real auth path: existing corrupt-credential cleanup - // still runs, and the broken plaintext fallback remains a hard error. - assert!(!fixture.config.join("credentials.enc").exists()); - assert!(!fixture.config.join("token_cache.json").exists()); - assert!(!fixture.config.join("sa_token_cache.json").exists()); assert!(fixture.network.requests.lock().unwrap().is_empty()); } @@ -493,12 +502,45 @@ fn docs_write_real_request_still_fails_on_broken_credentials() { } #[test] -fn raw_real_request_without_credentials_preserves_access_denied() { +fn raw_real_request_rejects_fixture_adc_without_profile_fallback() { let fixture = Fixture::new(false); let output = fixture.run(RAW, None); assert_eq!(output.status.code(), Some(2), "{output:?}"); let error: Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(error["error"]["reason"], "authError"); + let message = error["error"]["message"].as_str().unwrap(); + assert!( + message.contains("GOOGLE_APPLICATION_CREDENTIALS points to"), + "{error}" + ); + assert!( + message.contains( + fixture + .dir + .path() + .join("missing-adc.json") + .to_str() + .unwrap() + ), + "{error}" + ); + assert!(message.contains("file does not exist"), "{error}"); + assert!(fixture.network.requests.lock().unwrap().is_empty()); +} + +// This case must leave ADC unset to exercise the real no-credentials fallback. +// Only Unix dirs::home_dir() respects our HOME isolation; Windows uses the +// actual profile's known folder, so this case must not run there. +#[cfg(unix)] +#[test] +fn raw_real_request_without_credentials_preserves_access_denied() { + let fixture = Fixture::new(false); + let mut command = fixture.command(RAW, None); + command.env_remove("GOOGLE_APPLICATION_CREDENTIALS"); + let output = Fixture::run_command(command); + assert_eq!(output.status.code(), Some(2), "{output:?}"); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error"]["reason"], "authError"); assert!(error["error"]["message"] .as_str() .unwrap() @@ -510,6 +552,16 @@ fn raw_real_request_without_credentials_preserves_access_denied() { fn assert_authenticated_api_failure(args: &[&str]) { let fixture = Fixture::new(false); + // Make a mistaken profile fallback observable on Unix without using a real + // profile. Windows known-folder lookup ignores these home overrides, so the + // fixture must explicitly redirect ADC there as well. + let adc_dir = fixture.dir.path().join("home/.config/gcloud"); + fs::create_dir_all(&adc_dir).unwrap(); + fs::write( + adc_dir.join("application_default_credentials.json"), + r#"{"quota_project_id":"synthetic-profile-must-not-be-read"}"#, + ) + .unwrap(); let output = fixture.run(args, Some("synthetic-test-token")); assert_eq!(output.status.code(), Some(1), "{output:?}"); let error: Value = serde_json::from_slice(&output.stdout).unwrap(); @@ -520,6 +572,10 @@ fn assert_authenticated_api_failure(args: &[&str]) { assert!(requests[0] .to_lowercase() .contains("authorization: bearer synthetic-test-token")); + assert!( + !requests[0].to_lowercase().contains("x-goog-user-project:"), + "Token-authenticated requests must not read quota attribution from profile ADC" + ); } #[test] From f380058990c24b04366760969596ee69ae13e661 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 11 Sep 2026 14:17:32 -0300 Subject: [PATCH 4/4] test(cli): consume complete synthetic HTTP requests --- crates/google-workspace-cli/tests/dry_run.rs | 62 ++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/google-workspace-cli/tests/dry_run.rs b/crates/google-workspace-cli/tests/dry_run.rs index bcd578e8e..c760dccd4 100644 --- a/crates/google-workspace-cli/tests/dry_run.rs +++ b/crates/google-workspace-cli/tests/dry_run.rs @@ -57,6 +57,9 @@ impl NetworkTrap { let worker = thread::spawn(move || loop { match listener.accept() { Ok((mut stream, _)) => { + // Accepted sockets may inherit the listener's nonblocking + // mode. Request reads need the timeout below on every OS. + stream.set_nonblocking(false).unwrap(); // Record the connection even if the client fails before // sending HTTP (for example, during TLS/proxy setup). let mut requests = captured.lock().unwrap(); @@ -72,6 +75,25 @@ impl NetworkTrap { Ok(size) => header.extend_from_slice(&buffer[..size]), } } + // TCP may deliver headers and body in separate reads. + // Consume the body before closing the connection, or unread + // request bytes can reset it and hide our synthetic 403. + if let Some(end) = header.windows(4).position(|w| w == b"\r\n\r\n") { + let body_len = String::from_utf8_lossy(&header[..end]) + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().parse::().unwrap()) + .unwrap_or(0); + let expected_len = end + 4 + body_len; + while header.len() < expected_len { + let remaining = (expected_len - header.len()).min(buffer.len()); + match stream.read(&mut buffer[..remaining]) { + Ok(0) | Err(_) => break, + Ok(size) => header.extend_from_slice(&buffer[..size]), + } + } + } *requests.last_mut().unwrap() = String::from_utf8_lossy(&header).into_owned(); let body = r#"{"error":{"code":403,"message":"Synthetic denial","errors":[{"reason":"forbidden"}]}}"#; let _ = write!( @@ -587,3 +609,43 @@ fn raw_real_request_uses_token_and_preserves_api_failure() { fn docs_write_real_request_uses_token_and_preserves_api_failure() { assert_authenticated_api_failure(WRITE); } + +#[test] +fn network_trap_waits_for_split_request_body_before_responding() { + use std::net::TcpStream; + + let trap = NetworkTrap::new(); + let address = trap + .url + .strip_prefix("http://") + .unwrap() + .trim_end_matches('/'); + let mut stream = TcpStream::connect(address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + stream + .write_all(b"POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\n") + .unwrap(); + + let mut byte = [0; 1]; + let error = stream + .read(&mut byte) + .expect_err("must consume the body before responding"); + assert!(matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + )); + + stream.write_all(b"hello").unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!( + response.starts_with("HTTP/1.1 403 Forbidden\r\n"), + "{response}" + ); + assert!(trap.requests.lock().unwrap()[0].ends_with("hello")); +}