wfectl is a command-line client for wfe-server with 17 subcommands
covering the full workflow lifecycle:
* Auth: login (OAuth2 PKCE via Ory Hydra), logout, whoami
* Definitions: register (YAML → gRPC), validate (local compile),
definitions list
* Instances: run, get, list, cancel, suspend, resume
* Events: publish
* Streaming: watch (lifecycle), logs, search-logs (full-text)
Key design points:
* `validate` compiles YAML locally via `wfe-yaml::load_workflow_from_str`
with the full executor feature set enabled — instant feedback, no
server round-trip, no auth required. Uses the same compile path as
the server's `register` RPC so what passes validation is guaranteed
to register.
* Lookup commands accept either UUID or human name; the server
resolves the identifier for us. Display tables show both columns.
* `run --name <N>` lets users override the auto-generated
`{def_id}-{N}` instance name when they want a sticky reference.
* Table and JSON output formats, shared bearer-token or cached-login
auth path, direct token injection via `WFECTL_TOKEN`.
* 5 new unit tests for the validate command cover happy path, unknown
step type rejection, and missing file handling.
Dockerfile.ci ships the prebuilt image used as the `image:` for
kubernetes CI steps: rust stable, cargo-nextest, cargo-llvm-cov,
sccache (configured via WFE_SCCACHE_* env), buildctl for in-cluster
buildkitd, kubectl, tea for Gitea releases, and git. Published to
`src.sunbeam.pt/studio/wfe-ci:latest`.
262 lines
8.5 KiB
Rust
262 lines
8.5 KiB
Rust
//! Tests for OAuth2/OIDC code paths in auth.rs that talk to a real (mock) HTTP server.
|
|
|
|
use chrono::Utc;
|
|
use std::sync::Mutex;
|
|
use wiremock::matchers::{body_string_contains, method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
use wfectl::auth::{StoredToken, discover, ensure_valid, exchange_code, refresh, save_token};
|
|
|
|
static HOME_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
fn discovery_body(server: &MockServer) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"issuer": server.uri(),
|
|
"authorization_endpoint": format!("{}/oauth2/auth", server.uri()),
|
|
"token_endpoint": format!("{}/oauth2/token", server.uri()),
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_fetches_endpoints() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/.well-known/openid-configuration"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(discovery_body(&server)))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let doc = discover(&server.uri()).await.unwrap();
|
|
assert!(doc.authorization_endpoint.ends_with("/oauth2/auth"));
|
|
assert!(doc.token_endpoint.ends_with("/oauth2/token"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_handles_404() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/.well-known/openid-configuration"))
|
|
.respond_with(ResponseTemplate::new(404))
|
|
.mount(&server)
|
|
.await;
|
|
let result = discover(&server.uri()).await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discover_handles_invalid_json() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/.well-known/openid-configuration"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
|
|
.mount(&server)
|
|
.await;
|
|
let result = discover(&server.uri()).await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exchange_code_success() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth2/token"))
|
|
.and(body_string_contains("grant_type=authorization_code"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": "ory_at_new",
|
|
"refresh_token": "ory_rt_new",
|
|
"id_token": "header.eyJzdWIiOiJ1c2VyIn0.sig",
|
|
"expires_in": 3600,
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let discovery = wfectl::auth::DiscoveryDoc {
|
|
authorization_endpoint: format!("{}/oauth2/auth", server.uri()),
|
|
token_endpoint: format!("{}/oauth2/token", server.uri()),
|
|
};
|
|
let token = exchange_code(
|
|
&discovery,
|
|
"auth-code",
|
|
"verifier",
|
|
"http://127.0.0.1:9876/callback",
|
|
&server.uri(),
|
|
"test.com",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(token.access_token, "ory_at_new");
|
|
assert_eq!(token.refresh_token, Some("ory_rt_new".into()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exchange_code_handles_error_response() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth2/token"))
|
|
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
|
"error": "invalid_grant",
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let discovery = wfectl::auth::DiscoveryDoc {
|
|
authorization_endpoint: format!("{}/oauth2/auth", server.uri()),
|
|
token_endpoint: format!("{}/oauth2/token", server.uri()),
|
|
};
|
|
let result = exchange_code(&discovery, "bad", "v", "uri", &server.uri(), "test.com").await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_token_success() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/.well-known/openid-configuration"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(discovery_body(&server)))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth2/token"))
|
|
.and(body_string_contains("grant_type=refresh_token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": "ory_at_refreshed",
|
|
"expires_in": 3600,
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let token = StoredToken {
|
|
access_token: "ory_at_old".into(),
|
|
refresh_token: Some("ory_rt_old".into()),
|
|
id_token: None,
|
|
expires_at: Utc::now() - chrono::Duration::seconds(10),
|
|
issuer: server.uri(),
|
|
domain: "test.com".into(),
|
|
};
|
|
let refreshed = refresh(&token).await.unwrap();
|
|
assert_eq!(refreshed.access_token, "ory_at_refreshed");
|
|
// Old refresh token preserved when new one isn't returned.
|
|
assert_eq!(refreshed.refresh_token, Some("ory_rt_old".into()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_without_refresh_token_fails() {
|
|
let token = StoredToken {
|
|
access_token: "ory_at".into(),
|
|
refresh_token: None,
|
|
id_token: None,
|
|
expires_at: Utc::now(),
|
|
issuer: "https://auth.test.com/".into(),
|
|
domain: "test.com".into(),
|
|
};
|
|
let result = refresh(&token).await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_handles_token_endpoint_error() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/.well-known/openid-configuration"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(discovery_body(&server)))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth2/token"))
|
|
.respond_with(ResponseTemplate::new(401))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let token = StoredToken {
|
|
access_token: "ory_at_old".into(),
|
|
refresh_token: Some("ory_rt_old".into()),
|
|
id_token: None,
|
|
expires_at: Utc::now() - chrono::Duration::seconds(10),
|
|
issuer: server.uri(),
|
|
domain: "test.com".into(),
|
|
};
|
|
let result = refresh(&token).await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ensure_valid_returns_existing_when_fresh() {
|
|
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let saved = std::env::var("HOME").ok();
|
|
unsafe { std::env::set_var("HOME", tmp.path()) };
|
|
|
|
let token = StoredToken {
|
|
access_token: "ory_at_fresh".into(),
|
|
refresh_token: Some("ory_rt_x".into()),
|
|
id_token: None,
|
|
expires_at: Utc::now() + chrono::Duration::seconds(3600),
|
|
issuer: "https://auth.fresh.com/".into(),
|
|
domain: "fresh.com".into(),
|
|
};
|
|
save_token(&token).unwrap();
|
|
|
|
let result = ensure_valid("fresh.com").await.unwrap();
|
|
assert_eq!(result.access_token, "ory_at_fresh");
|
|
|
|
if let Some(h) = saved {
|
|
unsafe { std::env::set_var("HOME", h) };
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ensure_valid_refreshes_when_stale() {
|
|
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let saved = std::env::var("HOME").ok();
|
|
unsafe { std::env::set_var("HOME", tmp.path()) };
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/.well-known/openid-configuration"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(discovery_body(&server)))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/oauth2/token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": "ory_at_refreshed_via_ensure",
|
|
"expires_in": 3600,
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let domain = "stale.com";
|
|
let token = StoredToken {
|
|
access_token: "ory_at_old".into(),
|
|
refresh_token: Some("ory_rt_old".into()),
|
|
id_token: None,
|
|
expires_at: Utc::now() - chrono::Duration::seconds(10),
|
|
issuer: server.uri(),
|
|
domain: domain.into(),
|
|
};
|
|
save_token(&token).unwrap();
|
|
|
|
let result = ensure_valid(domain).await.unwrap();
|
|
assert_eq!(result.access_token, "ory_at_refreshed_via_ensure");
|
|
|
|
if let Some(h) = saved {
|
|
unsafe { std::env::set_var("HOME", h) };
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ensure_valid_errors_when_no_token() {
|
|
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let saved = std::env::var("HOME").ok();
|
|
unsafe { std::env::set_var("HOME", tmp.path()) };
|
|
|
|
let result = ensure_valid("nonexistent.com").await;
|
|
assert!(result.is_err());
|
|
|
|
if let Some(h) = saved {
|
|
unsafe { std::env::set_var("HOME", h) };
|
|
}
|
|
}
|