use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::config::config_dir;
pub(crate) const CREDENTIALS_FILE: &str = "credentials.json";
pub(crate) const DEFAULT_URL: &str = "https://pathbase.dev";
pub(crate) const PATHBASE_URL_ENV: &str = "PATHBASE_URL";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct StoredSession {
pub url: String,
pub token: String,
pub user: User,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct User {
pub id: String,
pub username: String,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub display_name: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct AnonGraphResponse {
pub id: String,
pub url: String,
}
#[derive(Debug, Clone)]
pub(crate) struct CreatedGraph {
pub id: String,
pub url: String,
pub visibility: pathbase_client::types::Visibility,
}
pub(crate) fn resolve_url(cli_url: Option<String>) -> String {
let raw = cli_url
.or_else(|| std::env::var(PATHBASE_URL_ENV).ok())
.unwrap_or_else(|| DEFAULT_URL.to_string());
raw.trim_end_matches('/').to_string()
}
pub(crate) fn host_of(url: &str) -> &str {
let after_scheme = match url.find("://") {
Some(i) => i + 3,
None => return url,
};
match url[after_scheme..].find('/') {
Some(off) => &url[..after_scheme + off],
None => url,
}
}
pub(crate) fn prompt_line(prompt: &str) -> Result<String> {
use std::io::{BufRead, Write};
let mut stdout = std::io::stdout();
stdout.write_all(prompt.as_bytes())?;
stdout.flush()?;
let stdin = std::io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line)?;
Ok(line.trim().to_string())
}
pub(crate) fn api_redeem(base_url: &str, code: &str) -> Result<(String, User)> {
let body = pathbase_client::types::RedeemBody {
code: code.to_string(),
};
let client = pathbase_client(base_url, None)?;
match block_on(client.cli_redeem(&body)) {
Ok(resp) => {
let inner = resp.into_inner();
let u = inner.user;
Ok((
inner.token,
User {
id: u.id.to_string(),
username: u.username,
email: u.email,
display_name: u.display_name,
},
))
}
Err(pathbase_client::Error::ErrorResponse(resp)) => match resp.status().as_u16() {
401 => bail!("code is invalid, already used, or expired — generate a new one"),
400 => bail!("invalid code format"),
code => bail!("redeem failed (HTTP {code})"),
},
Err(pathbase_client::Error::UnexpectedResponse(resp)) => {
let status = resp.status();
let body = block_on(resp.text()).unwrap_or_default();
let msg = error_message(&body).unwrap_or_else(|| short_body(&body));
if msg.is_empty() {
bail!("redeem failed ({status})")
} else {
bail!("redeem failed ({status}): {msg}")
}
}
Err(pathbase_client::Error::CommunicationError(e)) => {
bail!("connect to {base_url}: {}", reqwest_hint(&e))
}
Err(e) => Err(anyhow!("redeem failed: {}", full_chain(&e))),
}
}
pub(crate) fn api_logout(base_url: &str, token: &str) -> Result<()> {
let client = pathbase_client(base_url, Some(token))?;
let sessions = match block_on(client.list_sessions()) {
Ok(resp) => resp.into_inner(),
Err(pathbase_client::Error::ErrorResponse(resp)) => {
bail!("server returned {}", resp.status())
}
Err(pathbase_client::Error::UnexpectedResponse(resp)) => {
bail!("server returned {}", resp.status())
}
Err(pathbase_client::Error::CommunicationError(e)) => {
bail!("connect to {base_url}: {}", reqwest_hint(&e))
}
Err(e) => bail!("connect to {base_url}: {}", full_chain(&e)),
};
let current = sessions
.iter()
.find(|s| s.is_current)
.map(|s| s.id.as_str());
let Some(id_str) = current else {
return Ok(());
};
let id: uuid::Uuid = id_str
.parse()
.with_context(|| format!("server returned a non-UUID session id: {id_str}"))?;
match block_on(client.revoke_session(&id)) {
Ok(_) => Ok(()),
Err(pathbase_client::Error::ErrorResponse(resp)) => {
bail!("server returned {}", resp.status())
}
Err(pathbase_client::Error::UnexpectedResponse(resp)) => {
bail!("server returned {}", resp.status())
}
Err(pathbase_client::Error::CommunicationError(e)) => {
bail!("connect to {base_url}: {}", reqwest_hint(&e))
}
Err(e) => Err(anyhow!("connect to {base_url}: {}", full_chain(&e))),
}
}
pub(crate) fn api_me(base_url: &str, token: &str) -> Result<User> {
let client = pathbase_client(base_url, Some(token))?;
match block_on(client.get_me()) {
Ok(resp) => {
let u = resp.into_inner();
Ok(User {
id: u.id.to_string(),
username: u.username,
email: u.email,
display_name: u.display_name,
})
}
Err(pathbase_client::Error::ErrorResponse(resp)) => {
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
bail!("{base_url} rejected the stored credentials ({status})")
} else {
bail!("{base_url} returned {status} on /api/v1/users/me")
}
}
Err(pathbase_client::Error::UnexpectedResponse(resp)) => {
bail!("{base_url} returned {} on /api/v1/users/me", resp.status())
}
Err(pathbase_client::Error::InvalidResponsePayload(_, _)) => {
bail!("{base_url} isn't a Pathbase deployment (non-JSON /api/v1/users/me response)")
}
Err(pathbase_client::Error::CommunicationError(e)) => {
bail!("connect to {base_url}: {}", reqwest_hint(&e))
}
Err(e) => Err(anyhow!("connect to {base_url}: {}", full_chain(&e))),
}
}
#[derive(Debug)]
pub(crate) enum AuthMode {
Anon,
Authed { token: String, username: String },
}
pub(crate) fn preflight_auth(base_url: &str, anon: bool, needs_auth: bool) -> Result<AuthMode> {
if anon {
return Ok(AuthMode::Anon);
}
let stored = load_session(&credentials_path()?)?;
let go_anon = stored.is_none() && !needs_auth;
if go_anon {
eprintln!(
"note: not logged in — uploading anonymously (not listable). \
Run `path auth login --url {base_url}` for a listable upload."
);
return Ok(AuthMode::Anon);
}
let session = match stored {
Some(s) => s,
None => bail!("Not logged in. Run `path auth login` or pass `--anon`."),
};
if host_of(base_url) != host_of(&session.url) {
eprintln!(
"warning: stored credentials are for {}, but you're uploading to {}.",
session.url, base_url
);
}
match api_me(base_url, &session.token) {
Ok(user) => Ok(AuthMode::Authed {
token: session.token,
username: user.username,
}),
Err(e) if needs_auth => Err(e.context(format!(
"--repo / --public / --slug require an authenticated upload. \
Run `path auth login --url {base_url}` to authenticate against this \
server, or drop those flags to upload anonymously."
))),
Err(e) => {
eprintln!("note: {e}; falling back to anonymous upload.");
Ok(AuthMode::Anon)
}
}
}
fn short_body(body: &str) -> String {
const MAX: usize = 200;
let cleaned: String = body.replace(['\n', '\r'], " ");
let trimmed = cleaned.trim();
if trimmed.is_empty() {
return "<empty body>".to_string();
}
if trimmed.chars().count() > MAX {
let head: String = trimmed.chars().take(MAX - 1).collect();
format!("{head}…")
} else {
trimmed.to_string()
}
}
fn block_on<F: std::future::Future>(f: F) -> F::Output {
use std::sync::OnceLock;
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
let rt = RT.get_or_init(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build tokio runtime")
});
rt.block_on(f)
}
fn pathbase_client(base_url: &str, token: Option<&str>) -> Result<pathbase_client::Client> {
let mut builder = reqwest::Client::builder()
.user_agent(concat!("path-cli/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(30));
if let Some(t) = token {
let mut headers = reqwest::header::HeaderMap::new();
let mut auth = reqwest::header::HeaderValue::from_str(&format!("Bearer {t}"))
.context("invalid characters in auth token")?;
auth.set_sensitive(true);
headers.insert(reqwest::header::AUTHORIZATION, auth);
builder = builder.default_headers(headers);
}
let client = builder.build().context("build pathbase http client")?;
Ok(pathbase_client::Client::new_with_client(base_url, client))
}
fn parse_document(json: &str) -> Result<pathbase_client::types::ToolpathDocument> {
serde_json::from_str(json).context("parse toolpath document")
}
fn visibility_from_public_flag(public: bool) -> pathbase_client::types::Visibility {
use pathbase_client::types::Visibility;
if public {
Visibility::Public
} else {
Visibility::Unlisted
}
}
pub(crate) fn anon_graphs_post(base_url: &str, document_json: &str) -> Result<AnonGraphResponse> {
let body = pathbase_client::types::UploadGraphBody {
document: parse_document(document_json)?,
name: None,
visibility: None,
};
let client = pathbase_client(base_url, None)?;
match block_on(client.create_anon_graph(&body)) {
Ok(resp) => {
let inner = resp.into_inner();
Ok(AnonGraphResponse {
id: inner.id.to_string(),
url: inner.url,
})
}
Err(pathbase_client::Error::ErrorResponse(resp)) => match resp.status().as_u16() {
413 => bail!(
"anon upload exceeds the size cap — log in (`path auth login`) for a listable upload without that limit"
),
429 => bail!("anon upload rate-limited; retry shortly or log in"),
code => bail!("anon upload failed (HTTP {code})"),
},
Err(pathbase_client::Error::UnexpectedResponse(resp)) => {
let status = resp.status();
let body = block_on(resp.text()).unwrap_or_default();
let msg = error_message(&body).unwrap_or_else(|| short_body(&body));
if msg.is_empty() {
bail!("anon upload failed ({status})")
} else {
bail!("anon upload failed ({status}): {msg}")
}
}
Err(pathbase_client::Error::CommunicationError(e)) => {
bail!("anon upload failed: {}", reqwest_hint(&e))
}
Err(e) => Err(anyhow!("anon upload failed: {}", full_chain(&e))),
}
}
pub(crate) fn graphs_post(
base_url: &str,
token: &str,
owner: &str,
repo: &str,
name: Option<&str>,
document_json: &str,
public: bool,
) -> Result<CreatedGraph> {
let body = pathbase_client::types::UploadGraphBody {
document: parse_document(document_json)?,
name: name.map(|s| s.to_string()),
visibility: Some(visibility_from_public_flag(public)),
};
let client = pathbase_client(base_url, Some(token))?;
match block_on(client.create_graph(owner, repo, &body)) {
Ok(resp) => {
let inner = resp.into_inner();
Ok(CreatedGraph {
id: inner.id.to_string(),
url: inner.url,
visibility: inner.visibility,
})
}
Err(pathbase_client::Error::ErrorResponse(resp)) => match resp.status().as_u16() {
401 => bail!(
"{base_url} rejected your stored credentials (HTTP 401). \
Run `path auth login --url {base_url}` to authenticate against this server, \
or pass `--anon` to upload anonymously."
),
code => bail!("upload to {owner}/{repo} failed (HTTP {code})"),
},
Err(pathbase_client::Error::UnexpectedResponse(resp)) => {
let status = resp.status();
let body = block_on(resp.text()).unwrap_or_default();
let msg = error_message(&body).unwrap_or(body);
if msg.is_empty() {
bail!("upload to {owner}/{repo} returned unexpected status: HTTP {status}")
} else {
bail!("upload to {owner}/{repo} failed ({status}): {msg}")
}
}
Err(pathbase_client::Error::CommunicationError(e)) => {
bail!("upload to {owner}/{repo} failed: {}", reqwest_hint(&e))
}
Err(e) => Err(anyhow!(
"upload to {owner}/{repo} failed: {}",
full_chain(&e)
)),
}
}
fn error_message(body: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
}
fn full_chain(err: &(dyn std::error::Error + 'static)) -> String {
let mut s = err.to_string();
let mut cur = err.source();
while let Some(c) = cur {
s.push_str(": ");
s.push_str(&c.to_string());
cur = c.source();
}
s
}
fn reqwest_hint(err: &reqwest::Error) -> String {
if err.is_timeout() {
return "request timed out after 30s — try again, or shrink the upload".to_string();
}
if err.is_connect() {
return format!("couldn't connect to server: {}", full_chain(err));
}
if err.is_body() {
return format!("body error: {}", full_chain(err));
}
if err.is_decode() {
return format!("response decode error: {}", full_chain(err));
}
full_chain(err)
}
pub(crate) fn repos_post(base_url: &str, token: &str, owner: &str, name: &str) -> Result<()> {
let body = pathbase_client::types::CreateRepoBody {
name: name.to_string(),
description: None,
visibility: None,
};
let client = pathbase_client(base_url, Some(token))?;
match block_on(client.create_repo(owner, &body)) {
Ok(_) => Ok(()),
Err(pathbase_client::Error::ErrorResponse(resp)) => match resp.status().as_u16() {
401 => bail!(
"{base_url} rejected your stored credentials (HTTP 401). \
Run `path auth login --url {base_url}` to authenticate against this server, \
or pass `--anon` to upload anonymously."
),
409 => Ok(()),
code => bail!("creating repo {name} failed (HTTP {code})"),
},
Err(pathbase_client::Error::UnexpectedResponse(resp)) => match resp.status().as_u16() {
409 => Ok(()),
code => bail!("creating repo {name} returned unexpected status: HTTP {code}"),
},
Err(pathbase_client::Error::CommunicationError(e)) => {
bail!("creating repo {name} failed: {}", reqwest_hint(&e))
}
Err(e) => Err(anyhow!("creating repo {name} failed: {}", full_chain(&e))),
}
}
pub(crate) fn graphs_download(
base_url: &str,
token: Option<&str>,
owner: &str,
repo: &str,
id: &str,
) -> Result<String> {
let uuid: uuid::Uuid = id
.parse()
.with_context(|| format!("not a valid graph UUID: {id}"))?;
let client = pathbase_client(base_url, token)?;
match block_on(client.download_graph(owner, repo, &uuid)) {
Ok(resp) => {
let map = resp.into_inner();
serde_json::to_string(&map).context("re-serializing downloaded graph")
}
Err(pathbase_client::Error::ErrorResponse(resp)) => match resp.status() {
reqwest::StatusCode::NOT_FOUND => bail!(
"{owner}/{repo}/{id} not found on {base_url} (or it's a private graph \
and you're not the owner — run `path auth login --url {base_url}`)"
),
status => bail!("download of {owner}/{repo}/{id} failed ({status})"),
},
Err(pathbase_client::Error::UnexpectedResponse(resp)) => {
let status = resp.status();
let body = block_on(resp.text()).unwrap_or_default();
let msg = error_message(&body).unwrap_or_else(|| short_body(&body));
bail!("download of {owner}/{repo}/{id} failed ({status}): {msg}")
}
Err(pathbase_client::Error::CommunicationError(e)) => bail!(
"download of {owner}/{repo}/{id} failed: {}",
reqwest_hint(&e)
),
Err(e) => Err(anyhow!(
"download of {owner}/{repo}/{id} failed: {}",
full_chain(&e)
)),
}
}
pub(crate) fn credentials_path() -> Result<PathBuf> {
Ok(config_dir()?.join(CREDENTIALS_FILE))
}
pub(crate) fn store_session(path: &Path, s: &StoredSession) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("credentials path has no parent: {}", path.display()))?;
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
let payload = serde_json::to_string_pretty(s)?;
std::fs::write(path, payload).with_context(|| format!("write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("chmod 0600 {}", path.display()))?;
}
Ok(())
}
pub(crate) fn load_session(path: &Path) -> Result<Option<StoredSession>> {
match std::fs::read_to_string(path) {
Ok(s) if s.trim().is_empty() => Ok(None),
Ok(s) => Ok(Some(serde_json::from_str(&s).with_context(|| {
format!("decode credentials at {}", path.display())
})?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow!("read {}: {e}", path.display())),
}
}
pub(crate) fn clear_session(path: &Path) -> Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(anyhow!("remove {}: {e}", path.display())),
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
fn sample() -> StoredSession {
StoredSession {
url: "https://pathbase.dev".into(),
token: "tok".into(),
user: User {
id: "u1".into(),
username: "alice".into(),
email: Some("alice@example.com".into()),
display_name: None,
},
}
}
#[test]
fn resolve_url_prefers_cli_flag() {
let got = resolve_url(Some("https://example.com/".into()));
assert_eq!(got, "https://example.com");
}
#[test]
fn host_of_strips_path() {
assert_eq!(host_of("https://pathbase.dev"), "https://pathbase.dev");
assert_eq!(host_of("https://pathbase.dev/"), "https://pathbase.dev");
assert_eq!(
host_of("https://pathbase.dev/api/v1/traces"),
"https://pathbase.dev"
);
assert_eq!(
host_of("http://127.0.0.1:9000/foo"),
"http://127.0.0.1:9000"
);
assert_eq!(host_of("not-a-url"), "not-a-url");
}
#[test]
fn short_body_handles_empty_and_whitespace() {
assert_eq!(short_body(""), "<empty body>");
assert_eq!(short_body(" \n\t "), "<empty body>");
}
#[test]
fn short_body_collapses_newlines_to_spaces() {
assert_eq!(short_body("line1\nline2\r\nline3"), "line1 line2 line3");
}
#[test]
fn short_body_truncates_long_input_with_ellipsis() {
let long = "x".repeat(500);
let s = short_body(&long);
assert_eq!(s.chars().count(), 200);
assert!(s.ends_with('…'));
}
#[test]
fn store_then_load_roundtrips_on_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("credentials.json");
assert!(load_session(&path).unwrap().is_none());
store_session(&path, &sample()).unwrap();
let back = load_session(&path).unwrap().unwrap();
assert_eq!(back.user.username, "alice");
assert_eq!(back.token, "tok");
}
#[test]
fn store_creates_parent_directory() {
let dir = tempfile::tempdir().unwrap();
let path = dir
.path()
.join("nested")
.join("dir")
.join("credentials.json");
store_session(&path, &sample()).unwrap();
assert!(path.exists());
}
#[cfg(unix)]
#[test]
fn store_sets_restrictive_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("credentials.json");
store_session(&path, &sample()).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"expected 0600 on credentials file, got {mode:o}"
);
}
#[test]
fn clear_on_missing_file_is_ok() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nope.json");
assert!(clear_session(&path).is_ok());
}
#[test]
fn load_empty_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("credentials.json");
std::fs::write(&path, "").unwrap();
assert!(load_session(&path).unwrap().is_none());
}
pub(crate) struct MockServer {
port: u16,
thread: Option<std::thread::JoinHandle<Vec<u8>>>,
}
impl MockServer {
pub(crate) fn start(status_line: &'static str, body: &'static str) -> Self {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let thread = std::thread::spawn(move || {
let (mut stream, _addr) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut req = Vec::new();
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap() == 0 {
break;
}
req.extend_from_slice(line.as_bytes());
if line == "\r\n" {
break;
}
}
let content_length = req
.split(|b| *b == b'\n')
.find_map(|line| {
let line = std::str::from_utf8(line).ok()?;
let (name, value) = line.trim_end_matches('\r').split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
value.trim().parse::<usize>().ok()
} else {
None
}
})
.unwrap_or(0);
if content_length > 0 {
use std::io::Read;
let mut body_buf = vec![0u8; content_length];
reader.read_exact(&mut body_buf).ok();
req.extend_from_slice(&body_buf);
}
let response = format!(
"{status_line}\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
req
});
MockServer {
port,
thread: Some(thread),
}
}
pub(crate) fn base(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
fn request(mut self) -> Vec<u8> {
self.thread.take().unwrap().join().unwrap()
}
}
const TEST_UUID: &str = "fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537";
const TEST_REPO_UUID: &str = "00000000-0000-0000-0000-000000000002";
fn graph_document_json() -> String {
format!(
r#"{{
"id": "{TEST_UUID}",
"repo_id": "{TEST_REPO_UUID}",
"toolpath_id": "tp-1",
"document": {{"graph": {{"id":"g"}}, "paths": []}},
"path_count": 0,
"url": "https://pathbase.dev/u/alex/repos/pathstash/graphs/{TEST_UUID}",
"visibility": "unlisted",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}}"#
)
}
#[test]
fn graphs_post_wraps_document_with_name_and_visibility() {
let server = MockServer::start(
"HTTP/1.1 201 Created",
Box::leak(graph_document_json().into_boxed_str()),
);
let created = graphs_post(
&server.base(),
"tok",
"alex",
"pathstash",
Some("my-graph"),
r#"{"graph":{"id":"g"},"paths":[]}"#,
false,
)
.unwrap();
assert_eq!(created.id, TEST_UUID);
assert_eq!(
created.visibility,
pathbase_client::types::Visibility::Unlisted
);
let req = String::from_utf8(server.request()).unwrap();
assert!(
req.starts_with("POST /api/v1/u/alex/repos/pathstash/graphs "),
"got: {req}"
);
assert!(
req.to_lowercase().contains("authorization: bearer tok"),
"got: {req}"
);
assert!(req.contains(r#""name":"my-graph""#), "got: {req}");
assert!(req.contains(r#""visibility":"unlisted""#), "got: {req}");
assert!(
req.contains(r#""document":{"graph":{"id":"g"},"paths":[]}"#),
"got: {req}"
);
}
#[test]
fn graphs_post_401_surfaces_relogin_message() {
let server = MockServer::start(
"HTTP/1.1 401 Unauthorized",
r#"{"code":"unauthorized","error":"bad"}"#,
);
let base = server.base();
let err = graphs_post(
&base,
"tok",
"alex",
"pathstash",
None,
r#"{"graph":{"id":"g"},"paths":[]}"#,
false,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains(&base), "expected base URL in error: {msg}");
assert!(
msg.contains("path auth login --url"),
"expected re-auth hint: {msg}"
);
assert!(msg.contains("--anon"), "expected --anon hint: {msg}");
}
#[test]
fn graphs_post_5xx_includes_server_message() {
let server = MockServer::start(
"HTTP/1.1 500 Internal Server Error",
r#"{"error":"database is on fire"}"#,
);
let err = graphs_post(
&server.base(),
"tok",
"alex",
"pathstash",
None,
r#"{"graph":{"id":"g"},"paths":[]}"#,
false,
)
.unwrap_err();
assert!(err.to_string().contains("database is on fire"), "{err}");
}
#[test]
fn anon_graphs_post_wraps_document_and_omits_auth() {
let server = MockServer::start(
"HTTP/1.1 201 Created",
Box::leak(graph_document_json().into_boxed_str()),
);
let resp = anon_graphs_post(&server.base(), r#"{"graph":{"id":"g"},"paths":[]}"#).unwrap();
assert_eq!(resp.id, TEST_UUID);
assert!(resp.url.ends_with(TEST_UUID));
let req = String::from_utf8(server.request()).unwrap();
assert!(
req.starts_with("POST /api/v1/u/anon/repos/pathstash/graphs "),
"got: {req}"
);
assert!(
!req.to_lowercase().contains("authorization:"),
"anon must not send auth header: {req}"
);
assert!(
req.contains(r#""document":{"graph":{"id":"g"},"paths":[]}"#),
"got: {req}"
);
}
#[test]
fn anon_graphs_post_413_advises_login() {
let server = MockServer::start(
"HTTP/1.1 413 Payload Too Large",
r#"{"code":"bad_request","error":"body too large"}"#,
);
let err =
anon_graphs_post(&server.base(), r#"{"graph":{"id":"g"},"paths":[]}"#).unwrap_err();
assert!(err.to_string().contains("size cap"), "{err}");
assert!(err.to_string().contains("path auth login"), "{err}");
}
#[test]
fn repos_post_treats_409_as_success() {
let server = MockServer::start(
"HTTP/1.1 409 Conflict",
r#"{"code":"conflict","error":"already exists"}"#,
);
repos_post(&server.base(), "tok", "alex", "pathstash").unwrap();
}
#[test]
fn graphs_download_returns_body_as_json() {
let body = r#"{"graph":{"id":"g"},"paths":[{"path":{"id":"p1","head":"s1"},"steps":[]}]}"#;
let server = MockServer::start("HTTP/1.1 200 OK", body);
let got =
graphs_download(&server.base(), Some("tok"), "alex", "pathstash", TEST_UUID).unwrap();
let got_v: serde_json::Value = serde_json::from_str(&got).unwrap();
let want_v: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(
got_v, want_v,
"downloaded body should parse to the same value"
);
let req = String::from_utf8(server.request()).unwrap();
let expected_path =
format!("GET /api/v1/u/alex/repos/pathstash/graphs/{TEST_UUID}/download ");
assert!(req.starts_with(&expected_path), "got: {req}");
assert!(
req.to_lowercase().contains("authorization: bearer tok"),
"got: {req}"
);
}
#[test]
fn graphs_download_404_says_not_found() {
let server = MockServer::start(
"HTTP/1.1 404 Not Found",
r#"{"code":"not_found","error":"graph not found"}"#,
);
let err = graphs_download(&server.base(), Some("tok"), "alex", "pathstash", TEST_UUID)
.unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn graphs_download_rejects_non_uuid_id() {
let err = graphs_download(
"http://127.0.0.1:1",
Some("tok"),
"alex",
"pathstash",
"my-old-slug",
)
.unwrap_err();
assert!(err.to_string().contains("not a valid graph UUID"), "{err}");
}
fn write_credentials(dir: &std::path::Path, url: &str) {
let creds = StoredSession {
url: url.to_string(),
token: "tok".into(),
user: User {
id: "u1".into(),
username: "alice".into(),
email: None,
display_name: None,
},
};
store_session(&dir.join(CREDENTIALS_FILE), &creds).unwrap();
}
fn me_response_body(username: &str) -> String {
format!(
r#"{{"id":"00000000-0000-0000-0000-000000000001","username":"{username}","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}}"#
)
}
#[test]
fn preflight_anon_when_logged_out_and_no_auth_flags() {
let cfg = tempfile::tempdir().unwrap();
let _g = EnvGuard::set("TOOLPATH_CONFIG_DIR", cfg.path().to_str().unwrap());
let mode = preflight_auth("https://pathbase.dev", false, false).unwrap();
assert!(matches!(mode, AuthMode::Anon));
}
#[test]
fn preflight_authed_when_credentials_validate() {
let server = MockServer::start(
"HTTP/1.1 200 OK",
Box::leak(me_response_body("alice").into_boxed_str()),
);
let cfg = tempfile::tempdir().unwrap();
let _g = EnvGuard::set("TOOLPATH_CONFIG_DIR", cfg.path().to_str().unwrap());
write_credentials(cfg.path(), &server.base());
let base = server.base();
let mode = preflight_auth(&base, false, false).unwrap();
match mode {
AuthMode::Authed { username, .. } => assert_eq!(username, "alice"),
AuthMode::Anon => panic!("expected Authed, got Anon"),
}
}
#[test]
fn preflight_falls_back_to_anon_on_401_without_auth_flags() {
let server = MockServer::start("HTTP/1.1 401 Unauthorized", "{}");
let cfg = tempfile::tempdir().unwrap();
let _g = EnvGuard::set("TOOLPATH_CONFIG_DIR", cfg.path().to_str().unwrap());
write_credentials(cfg.path(), &server.base());
let base = server.base();
let mode = preflight_auth(&base, false, false).unwrap();
assert!(matches!(mode, AuthMode::Anon));
}
#[test]
fn preflight_propagates_401_when_auth_required() {
let server = MockServer::start("HTTP/1.1 401 Unauthorized", "{}");
let cfg = tempfile::tempdir().unwrap();
let _g = EnvGuard::set("TOOLPATH_CONFIG_DIR", cfg.path().to_str().unwrap());
write_credentials(cfg.path(), &server.base());
let base = server.base();
let err = preflight_auth(&base, false, true).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("--repo"), "expected mention of --repo: {msg}");
}
#[test]
fn preflight_anon_flag_skips_credentials_check() {
let cfg = tempfile::tempdir().unwrap();
let _g = EnvGuard::set("TOOLPATH_CONFIG_DIR", cfg.path().to_str().unwrap());
write_credentials(cfg.path(), "https://pathbase.dev");
let mode = preflight_auth("https://pathbase.dev", true, false).unwrap();
assert!(matches!(mode, AuthMode::Anon));
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct EnvGuard {
key: String,
prior: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl EnvGuard {
fn set(key: &str, val: &str) -> Self {
let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var_os(key);
unsafe {
std::env::set_var(key, val);
}
Self {
key: key.to_string(),
prior,
_lock: lock,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.prior {
Some(v) => std::env::set_var(&self.key, v),
None => std::env::remove_var(&self.key),
}
}
}
}
}