feat(wfectl): new CLI client + wfe-ci builder image

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`.
This commit is contained in:
2026-04-07 19:09:26 +01:00
parent 34209470c3
commit 0c239cd484
32 changed files with 4206 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
//! Tests for `whoami` and `logout` commands using a temp HOME with a fake token.
use chrono::Utc;
use std::path::PathBuf;
use std::sync::Mutex;
use wfectl::auth::{StoredToken, save_token};
use wfectl::commands::{logout, whoami};
use wfectl::config::Config;
use wfectl::output::OutputFormat;
/// Serialize HOME-mutating tests so they don't race.
static HOME_LOCK: Mutex<()> = Mutex::new(());
fn make_test_token(domain: &str) -> StoredToken {
StoredToken {
access_token: "ory_at_test".into(),
refresh_token: Some("ory_rt_test".into()),
// {"email":"alice@test.com","name":"Alice","groups":["admin","employee"]}
id_token: Some("h.eyJlbWFpbCI6ImFsaWNlQHRlc3QuY29tIiwibmFtZSI6IkFsaWNlIiwiZ3JvdXBzIjpbImFkbWluIiwiZW1wbG95ZWUiXX0.s".into()),
expires_at: Utc::now() + chrono::Duration::seconds(3600),
issuer: "https://auth.test.com/".into(),
domain: domain.into(),
}
}
fn with_temp_home<F: FnOnce(PathBuf)>(f: F) {
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
let saved_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
f(tmp.path().to_path_buf());
if let Some(h) = saved_home {
unsafe { std::env::set_var("HOME", h) };
}
}
#[tokio::test]
async fn whoami_table_format_with_full_claims() {
with_temp_home(|_| {});
// Re-acquire the lock for the async section.
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
let saved_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
let token = make_test_token("test.com");
save_token(&token).unwrap();
let cfg = Config {
server: "http://localhost".into(),
issuer: "https://auth.test.com/".into(),
default_format: Default::default(),
};
let args = whoami::WhoamiArgs { issuer: None };
whoami::run(args, &cfg, OutputFormat::Table).await.unwrap();
if let Some(h) = saved_home {
unsafe { std::env::set_var("HOME", h) };
}
}
#[tokio::test]
async fn whoami_json_format() {
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
let saved_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
let token = make_test_token("test.com");
save_token(&token).unwrap();
let cfg = Config {
server: "http://localhost".into(),
issuer: "https://auth.test.com/".into(),
default_format: Default::default(),
};
let args = whoami::WhoamiArgs { issuer: None };
whoami::run(args, &cfg, OutputFormat::Json).await.unwrap();
if let Some(h) = saved_home {
unsafe { std::env::set_var("HOME", h) };
}
}
#[tokio::test]
async fn whoami_when_not_logged_in() {
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
let saved_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
let cfg = Config {
server: "http://localhost".into(),
issuer: "https://auth.notlogged.in/".into(),
default_format: Default::default(),
};
let args = whoami::WhoamiArgs { issuer: None };
// Should not panic, just print a message.
whoami::run(args, &cfg, OutputFormat::Table).await.unwrap();
if let Some(h) = saved_home {
unsafe { std::env::set_var("HOME", h) };
}
}
#[tokio::test]
async fn whoami_with_explicit_issuer_arg() {
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
let saved_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
let token = make_test_token("override.com");
save_token(&token).unwrap();
let cfg = Config::default();
let args = whoami::WhoamiArgs {
issuer: Some("https://auth.override.com/".into()),
};
whoami::run(args, &cfg, OutputFormat::Table).await.unwrap();
if let Some(h) = saved_home {
unsafe { std::env::set_var("HOME", h) };
}
}
#[tokio::test]
async fn logout_removes_token() {
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
let saved_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
let token = make_test_token("test.com");
save_token(&token).unwrap();
let cfg = Config {
server: "http://localhost".into(),
issuer: "https://auth.test.com/".into(),
default_format: Default::default(),
};
let args = logout::LogoutArgs { issuer: None };
logout::run(args, &cfg).await.unwrap();
// Verify the token file is gone.
let path = wfectl::auth::token_path("test.com");
assert!(!path.exists());
if let Some(h) = saved_home {
unsafe { std::env::set_var("HOME", h) };
}
}
#[tokio::test]
async fn logout_when_not_logged_in_is_noop() {
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
let saved_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
let cfg = Config {
server: "http://localhost".into(),
issuer: "https://auth.nothing.com/".into(),
default_format: Default::default(),
};
let args = logout::LogoutArgs { issuer: None };
logout::run(args, &cfg).await.unwrap();
if let Some(h) = saved_home {
unsafe { std::env::set_var("HOME", h) };
}
}
#[tokio::test]
async fn logout_with_explicit_issuer() {
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
let saved_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path()) };
let token = make_test_token("explicit.com");
save_token(&token).unwrap();
let cfg = Config::default();
let args = logout::LogoutArgs {
issuer: Some("https://auth.explicit.com/".into()),
};
logout::run(args, &cfg).await.unwrap();
if let Some(h) = saved_home {
unsafe { std::env::set_var("HOME", h) };
}
}

261
wfectl/tests/auth_oidc.rs Normal file
View File

@@ -0,0 +1,261 @@
//! 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) };
}
}

401
wfectl/tests/commands.rs Normal file
View File

@@ -0,0 +1,401 @@
//! Drive the wfectl command handlers against a stub gRPC server. This tests
//! the full command -> client -> server -> response -> formatter pipeline.
mod stub;
use std::path::PathBuf;
use wfectl::client::build as build_client;
use wfectl::commands;
use wfectl::output::OutputFormat;
use stub::spawn_stub;
#[tokio::test]
async fn register_command_table_output() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
// Write a temp YAML file
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "workflow:\n id: x\n version: 1\n steps: []").unwrap();
let args = commands::register::RegisterArgs {
file: tmp.path().to_path_buf(),
config: vec![("key".into(), "val".into())],
};
commands::register::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn register_command_json_output() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "workflow:\n id: x\n version: 1\n steps: []").unwrap();
let args = commands::register::RegisterArgs {
file: tmp.path().to_path_buf(),
config: vec![],
};
commands::register::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn register_command_missing_file_errors() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::register::RegisterArgs {
file: PathBuf::from("/nonexistent/file.yaml"),
config: vec![],
};
let result = commands::register::run(args, client, OutputFormat::Table).await;
assert!(result.is_err());
}
#[tokio::test]
async fn definitions_list_table() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::definitions::DefinitionsArgs {
cmd: commands::definitions::DefinitionsCmd::List,
};
commands::definitions::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn definitions_list_json() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::definitions::DefinitionsArgs {
cmd: commands::definitions::DefinitionsCmd::List,
};
commands::definitions::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn run_command_with_inline_data() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::run::RunArgs {
definition_id: "ci".into(),
version: 1,
data: None,
data_json: Some(r#"{"key":"value"}"#.into()),
name: None,
};
commands::run::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn run_command_with_data_file() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), r#"{"deploy":true}"#).unwrap();
let args = commands::run::RunArgs {
definition_id: "ci".into(),
version: 2,
data: Some(tmp.path().to_path_buf()),
data_json: None,
name: None,
};
commands::run::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn run_command_no_data() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::run::RunArgs {
definition_id: "ci".into(),
version: 1,
data: None,
data_json: None,
name: None,
};
commands::run::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn run_command_invalid_json_errors() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::run::RunArgs {
definition_id: "ci".into(),
version: 1,
data: None,
data_json: Some("not json".into()),
name: None,
};
let result = commands::run::run(args, client, OutputFormat::Table).await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_command_table() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::get::GetArgs {
workflow_id: "wf-1".into(),
};
commands::get::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn get_command_json() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::get::GetArgs {
workflow_id: "wf-1".into(),
};
commands::get::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn list_command_no_filters() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::list::ListArgs {
query: None,
status: None,
limit: 10,
skip: 0,
};
commands::list::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn list_command_all_filters() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::list::ListArgs {
query: Some("foo".into()),
status: Some(commands::list::StatusFilter::Complete),
limit: 50,
skip: 10,
};
commands::list::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn list_command_each_status_variant() {
use commands::list::StatusFilter;
for status in [
StatusFilter::Runnable,
StatusFilter::Suspended,
StatusFilter::Complete,
StatusFilter::Terminated,
] {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::list::ListArgs {
query: None,
status: Some(status),
limit: 10,
skip: 0,
};
commands::list::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
}
#[tokio::test]
async fn cancel_suspend_resume_commands() {
let (server, _seen) = spawn_stub().await;
let client1 = build_client(&server, "test-token").await.unwrap();
let client2 = build_client(&server, "test-token").await.unwrap();
let client3 = build_client(&server, "test-token").await.unwrap();
commands::cancel::run(
commands::cancel::CancelArgs {
workflow_id: "wf-1".into(),
},
client1,
)
.await
.unwrap();
commands::suspend::run(
commands::suspend::SuspendArgs {
workflow_id: "wf-1".into(),
},
client2,
)
.await
.unwrap();
commands::resume::run(
commands::resume::ResumeArgs {
workflow_id: "wf-1".into(),
},
client3,
)
.await
.unwrap();
}
#[tokio::test]
async fn publish_command_with_inline_data() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::publish::PublishArgs {
event_name: "order.paid".into(),
event_key: "order-42".into(),
data: None,
data_json: Some(r#"{"amount":100}"#.into()),
};
commands::publish::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn publish_command_with_file() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), r#"{"amount":100}"#).unwrap();
let args = commands::publish::PublishArgs {
event_name: "order.paid".into(),
event_key: "order-42".into(),
data: Some(tmp.path().to_path_buf()),
data_json: None,
};
commands::publish::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn publish_command_no_data() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::publish::PublishArgs {
event_name: "test".into(),
event_key: "x".into(),
data: None,
data_json: None,
};
commands::publish::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn watch_command_streams_events() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::watch::WatchArgs {
workflow_id: Some("wf-1".into()),
};
commands::watch::run(args, client).await.unwrap();
}
#[tokio::test]
async fn watch_command_no_filter() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::watch::WatchArgs { workflow_id: None };
commands::watch::run(args, client).await.unwrap();
}
#[tokio::test]
async fn logs_command_basic() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::logs::LogsArgs {
workflow_id: "wf-1".into(),
step: None,
follow: false,
};
commands::logs::run(args, client).await.unwrap();
}
#[tokio::test]
async fn logs_command_with_step_filter() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::logs::LogsArgs {
workflow_id: "wf-1".into(),
step: Some("build".into()),
follow: true,
};
commands::logs::run(args, client).await.unwrap();
}
#[tokio::test]
async fn search_logs_command_table() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::search_logs::SearchLogsArgs {
query: "needle".into(),
workflow: None,
step: None,
stream: None,
limit: 10,
skip: 0,
};
commands::search_logs::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn search_logs_command_with_filters_json() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::search_logs::SearchLogsArgs {
query: "needle".into(),
workflow: Some("wf-1".into()),
step: Some("build".into()),
stream: Some(commands::search_logs::StreamFilter::Stdout),
limit: 100,
skip: 5,
};
commands::search_logs::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn search_logs_each_stream_variant() {
use commands::search_logs::StreamFilter;
for stream in [StreamFilter::Stdout, StreamFilter::Stderr] {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::search_logs::SearchLogsArgs {
query: "x".into(),
workflow: None,
step: None,
stream: Some(stream),
limit: 10,
skip: 0,
};
commands::search_logs::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
}

220
wfectl/tests/integration.rs Normal file
View File

@@ -0,0 +1,220 @@
//! Raw gRPC client tests against the in-process stub server.
//! Verifies the wire protocol and bearer auth interceptor.
mod stub;
use wfe_server_protos::wfe::v1::{
CancelWorkflowRequest, GetWorkflowRequest, ListDefinitionsRequest, PublishEventRequest,
RegisterWorkflowRequest, ResumeWorkflowRequest, SearchLogsRequest, SearchWorkflowsRequest,
StartWorkflowRequest, SuspendWorkflowRequest, WatchLifecycleRequest, WorkflowStatus,
};
use wfectl::client::build as build_client;
use stub::spawn_stub;
#[tokio::test]
async fn client_register_workflow() {
let (server, _seen) = spawn_stub().await;
let mut client = build_client(&server, "test-token").await.unwrap();
let resp = client
.register_workflow(RegisterWorkflowRequest {
yaml: "workflow:\n id: x\n version: 1\n steps: []".into(),
config: Default::default(),
})
.await
.unwrap()
.into_inner();
assert_eq!(resp.definitions.len(), 1);
assert_eq!(resp.definitions[0].step_count, 3);
}
#[tokio::test]
async fn client_list_definitions() {
let (server, _seen) = spawn_stub().await;
let mut client = build_client(&server, "test-token").await.unwrap();
let resp = client
.list_definitions(ListDefinitionsRequest {})
.await
.unwrap()
.into_inner();
assert_eq!(resp.definitions.len(), 1);
assert_eq!(resp.definitions[0].id, "ci");
}
#[tokio::test]
async fn client_start_workflow_with_data() {
let (server, _seen) = spawn_stub().await;
let mut client = build_client(&server, "test-token").await.unwrap();
let resp = client
.start_workflow(StartWorkflowRequest {
definition_id: "ci".into(),
version: 1,
data: Some(wfectl::struct_util::json_object_to_struct(
&serde_json::json!({"key": "value"}),
)),
name: String::new(),
})
.await
.unwrap()
.into_inner();
assert_eq!(resp.workflow_id, "wf-ci-1");
}
#[tokio::test]
async fn client_get_workflow_returns_instance() {
let (server, _seen) = spawn_stub().await;
let mut client = build_client(&server, "test-token").await.unwrap();
let resp = client
.get_workflow(GetWorkflowRequest {
workflow_id: "wf-ci-1".into(),
})
.await
.unwrap()
.into_inner();
let instance = resp.instance.unwrap();
assert_eq!(instance.id, "wf-ci-1");
assert_eq!(instance.definition_id, "ci");
}
#[tokio::test]
async fn client_cancel_suspend_resume() {
let (server, _seen) = spawn_stub().await;
let mut client = build_client(&server, "test-token").await.unwrap();
client
.cancel_workflow(CancelWorkflowRequest {
workflow_id: "wf-1".into(),
})
.await
.unwrap();
client
.suspend_workflow(SuspendWorkflowRequest {
workflow_id: "wf-1".into(),
})
.await
.unwrap();
client
.resume_workflow(ResumeWorkflowRequest {
workflow_id: "wf-1".into(),
})
.await
.unwrap();
}
#[tokio::test]
async fn client_search_workflows() {
let (server, _seen) = spawn_stub().await;
let mut client = build_client(&server, "test-token").await.unwrap();
let resp = client
.search_workflows(SearchWorkflowsRequest {
query: "ci".into(),
status_filter: WorkflowStatus::Complete as i32,
skip: 0,
take: 10,
})
.await
.unwrap()
.into_inner();
assert_eq!(resp.total, 1);
assert_eq!(resp.results[0].id, "wf-1");
}
#[tokio::test]
async fn client_publish_event() {
let (server, _seen) = spawn_stub().await;
let mut client = build_client(&server, "test-token").await.unwrap();
let resp = client
.publish_event(PublishEventRequest {
event_name: "order.paid".into(),
event_key: "order-42".into(),
data: None,
})
.await
.unwrap()
.into_inner();
assert_eq!(resp.event_id, "evt-order.paid-order-42");
}
#[tokio::test]
async fn client_watch_lifecycle_stream() {
use futures::StreamExt;
let (server, _seen) = spawn_stub().await;
let mut client = build_client(&server, "test-token").await.unwrap();
let mut stream = client
.watch_lifecycle(WatchLifecycleRequest {
workflow_id: String::new(),
})
.await
.unwrap()
.into_inner();
let mut count = 0;
while let Some(event) = stream.next().await {
let event = event.unwrap();
assert_eq!(event.workflow_id, "wf-1");
count += 1;
}
assert_eq!(count, 2);
}
#[tokio::test]
async fn client_stream_logs() {
use futures::StreamExt;
let (server, _seen) = spawn_stub().await;
let mut client = build_client(&server, "test-token").await.unwrap();
let mut stream = client
.stream_logs(wfe_server_protos::wfe::v1::StreamLogsRequest {
workflow_id: "wf-1".into(),
step_name: String::new(),
follow: false,
})
.await
.unwrap()
.into_inner();
let entry = stream.next().await.unwrap().unwrap();
assert_eq!(entry.step_name, "build");
assert_eq!(entry.data, b"hello\n");
}
#[tokio::test]
async fn client_search_logs() {
let (server, _seen) = spawn_stub().await;
let mut client = build_client(&server, "test-token").await.unwrap();
let resp = client
.search_logs(SearchLogsRequest {
query: "needle".into(),
workflow_id: String::new(),
step_name: String::new(),
stream_filter: 0,
skip: 0,
take: 10,
})
.await
.unwrap()
.into_inner();
assert_eq!(resp.total, 1);
assert!(resp.results[0].line.contains("needle"));
}
#[tokio::test]
async fn auth_interceptor_sends_bearer_header() {
let (server, seen) = spawn_stub().await;
let mut client = build_client(&server, "ory_at_test_token").await.unwrap();
client
.list_definitions(ListDefinitionsRequest {})
.await
.unwrap();
let captured = seen.lock().await.clone();
assert_eq!(captured, Some("Bearer ory_at_test_token".to_string()));
}
#[tokio::test]
async fn empty_token_sends_no_header() {
let (server, seen) = spawn_stub().await;
let mut client = build_client(&server, "").await.unwrap();
client
.list_definitions(ListDefinitionsRequest {})
.await
.unwrap();
let captured = seen.lock().await.clone();
assert!(captured.is_none());
}

312
wfectl/tests/stub/mod.rs Normal file
View File

@@ -0,0 +1,312 @@
//! Shared in-process gRPC stub server for command and integration tests.
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::Mutex;
use tonic::transport::Server;
use tonic::{Request, Response, Status};
use wfe_server_protos::wfe::v1::{
CancelWorkflowRequest, CancelWorkflowResponse, DefinitionSummary, GetWorkflowRequest,
GetWorkflowResponse, ListDefinitionsRequest, ListDefinitionsResponse, PublishEventRequest,
PublishEventResponse, RegisterWorkflowRequest, RegisterWorkflowResponse, RegisteredDefinition,
ResumeWorkflowRequest, ResumeWorkflowResponse, SearchLogsRequest, SearchLogsResponse,
SearchWorkflowsRequest, SearchWorkflowsResponse, StartWorkflowRequest, StartWorkflowResponse,
SuspendWorkflowRequest, SuspendWorkflowResponse, WatchLifecycleRequest, WorkflowInstance,
WorkflowSearchResult, WorkflowStatus,
wfe_server::{Wfe, WfeServer},
};
#[derive(Default)]
pub struct StubWfe {
pub seen_authorization: Arc<Mutex<Option<String>>>,
}
impl StubWfe {
async fn capture_auth<T>(&self, req: &Request<T>) {
if let Some(val) = req.metadata().get("authorization") {
if let Ok(s) = val.to_str() {
let mut guard = self.seen_authorization.lock().await;
*guard = Some(s.to_string());
}
}
}
}
#[tonic::async_trait]
impl Wfe for StubWfe {
async fn register_workflow(
&self,
req: Request<RegisterWorkflowRequest>,
) -> Result<Response<RegisterWorkflowResponse>, Status> {
self.capture_auth(&req).await;
let inner = req.into_inner();
Ok(Response::new(RegisterWorkflowResponse {
definitions: vec![RegisteredDefinition {
definition_id: format!("test-{}", inner.yaml.len()),
version: 1,
step_count: 3,
name: "Test Workflow".into(),
}],
}))
}
async fn list_definitions(
&self,
req: Request<ListDefinitionsRequest>,
) -> Result<Response<ListDefinitionsResponse>, Status> {
self.capture_auth(&req).await;
Ok(Response::new(ListDefinitionsResponse {
definitions: vec![DefinitionSummary {
id: "ci".into(),
version: 1,
description: "CI pipeline".into(),
step_count: 5,
name: "Continuous Integration".into(),
}],
}))
}
async fn start_workflow(
&self,
req: Request<StartWorkflowRequest>,
) -> Result<Response<StartWorkflowResponse>, Status> {
self.capture_auth(&req).await;
let inner = req.into_inner();
let workflow_id = format!("wf-{}-{}", inner.definition_id, inner.version);
let name = if inner.name.is_empty() {
format!("{}-1", inner.definition_id)
} else {
inner.name
};
Ok(Response::new(StartWorkflowResponse { workflow_id, name }))
}
async fn get_workflow(
&self,
req: Request<GetWorkflowRequest>,
) -> Result<Response<GetWorkflowResponse>, Status> {
self.capture_auth(&req).await;
let id = req.into_inner().workflow_id;
Ok(Response::new(GetWorkflowResponse {
instance: Some(WorkflowInstance {
id: id.clone(),
name: "ci-1".into(),
definition_id: "ci".into(),
version: 1,
description: "test instance".into(),
reference: "ref-1".into(),
status: WorkflowStatus::Runnable as i32,
data: None,
create_time: Some(prost_types::Timestamp {
seconds: 1_700_000_000,
nanos: 0,
}),
complete_time: None,
execution_pointers: vec![wfe_server_protos::wfe::v1::ExecutionPointer {
id: "ptr-1".into(),
step_id: 0,
step_name: "build".into(),
status: wfe_server_protos::wfe::v1::PointerStatus::Complete as i32,
start_time: Some(prost_types::Timestamp {
seconds: 1_700_000_000,
nanos: 0,
}),
end_time: Some(prost_types::Timestamp {
seconds: 1_700_000_100,
nanos: 0,
}),
retry_count: 0,
active: false,
}],
}),
}))
}
async fn cancel_workflow(
&self,
req: Request<CancelWorkflowRequest>,
) -> Result<Response<CancelWorkflowResponse>, Status> {
self.capture_auth(&req).await;
Ok(Response::new(CancelWorkflowResponse {}))
}
async fn suspend_workflow(
&self,
req: Request<SuspendWorkflowRequest>,
) -> Result<Response<SuspendWorkflowResponse>, Status> {
self.capture_auth(&req).await;
Ok(Response::new(SuspendWorkflowResponse {}))
}
async fn resume_workflow(
&self,
req: Request<ResumeWorkflowRequest>,
) -> Result<Response<ResumeWorkflowResponse>, Status> {
self.capture_auth(&req).await;
Ok(Response::new(ResumeWorkflowResponse {}))
}
async fn search_workflows(
&self,
req: Request<SearchWorkflowsRequest>,
) -> Result<Response<SearchWorkflowsResponse>, Status> {
self.capture_auth(&req).await;
Ok(Response::new(SearchWorkflowsResponse {
results: vec![WorkflowSearchResult {
id: "wf-1".into(),
name: "ci-1".into(),
definition_id: "ci".into(),
version: 1,
status: WorkflowStatus::Complete as i32,
reference: "ref-1".into(),
description: "test".into(),
create_time: Some(prost_types::Timestamp {
seconds: 1_700_000_000,
nanos: 0,
}),
}],
total: 1,
}))
}
async fn publish_event(
&self,
req: Request<PublishEventRequest>,
) -> Result<Response<PublishEventResponse>, Status> {
self.capture_auth(&req).await;
let event = req.into_inner();
Ok(Response::new(PublishEventResponse {
event_id: format!("evt-{}-{}", event.event_name, event.event_key),
}))
}
type WatchLifecycleStream = tokio_stream::wrappers::ReceiverStream<
Result<wfe_server_protos::wfe::v1::LifecycleEvent, Status>,
>;
async fn watch_lifecycle(
&self,
req: Request<WatchLifecycleRequest>,
) -> Result<Response<Self::WatchLifecycleStream>, Status> {
self.capture_auth(&req).await;
let (tx, rx) = tokio::sync::mpsc::channel(4);
let _ = tx
.send(Ok(wfe_server_protos::wfe::v1::LifecycleEvent {
event_time: Some(prost_types::Timestamp {
seconds: 1_700_000_000,
nanos: 0,
}),
workflow_id: "wf-1".into(),
definition_id: "ci".into(),
version: 1,
event_type: wfe_server_protos::wfe::v1::LifecycleEventType::Started as i32,
step_id: 0,
step_name: String::new(),
error_message: String::new(),
}))
.await;
let _ = tx
.send(Ok(wfe_server_protos::wfe::v1::LifecycleEvent {
event_time: Some(prost_types::Timestamp {
seconds: 1_700_000_001,
nanos: 0,
}),
workflow_id: "wf-1".into(),
definition_id: "ci".into(),
version: 1,
event_type: wfe_server_protos::wfe::v1::LifecycleEventType::StepCompleted as i32,
step_id: 1,
step_name: "build".into(),
error_message: String::new(),
}))
.await;
drop(tx);
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
rx,
)))
}
type StreamLogsStream = tokio_stream::wrappers::ReceiverStream<
Result<wfe_server_protos::wfe::v1::LogEntry, Status>,
>;
async fn stream_logs(
&self,
req: Request<wfe_server_protos::wfe::v1::StreamLogsRequest>,
) -> Result<Response<Self::StreamLogsStream>, Status> {
self.capture_auth(&req).await;
let (tx, rx) = tokio::sync::mpsc::channel(4);
let _ = tx
.send(Ok(wfe_server_protos::wfe::v1::LogEntry {
workflow_id: "wf-1".into(),
step_name: "build".into(),
step_id: 0,
stream: wfe_server_protos::wfe::v1::LogStream::Stdout as i32,
data: b"hello\n".to_vec(),
timestamp: Some(prost_types::Timestamp {
seconds: 1_700_000_000,
nanos: 0,
}),
}))
.await;
let _ = tx
.send(Ok(wfe_server_protos::wfe::v1::LogEntry {
workflow_id: "wf-1".into(),
step_name: "build".into(),
step_id: 0,
stream: wfe_server_protos::wfe::v1::LogStream::Stderr as i32,
data: b"warning\n".to_vec(),
timestamp: Some(prost_types::Timestamp {
seconds: 1_700_000_001,
nanos: 0,
}),
}))
.await;
drop(tx);
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(
rx,
)))
}
async fn search_logs(
&self,
req: Request<SearchLogsRequest>,
) -> Result<Response<SearchLogsResponse>, Status> {
self.capture_auth(&req).await;
let inner = req.into_inner();
Ok(Response::new(SearchLogsResponse {
results: vec![wfe_server_protos::wfe::v1::LogSearchResult {
workflow_id: "wf-1".into(),
definition_id: "ci".into(),
step_name: "build".into(),
line: format!("matched {}", inner.query),
stream: wfe_server_protos::wfe::v1::LogStream::Stdout as i32,
timestamp: Some(prost_types::Timestamp {
seconds: 1_700_000_000,
nanos: 0,
}),
}],
total: 1,
}))
}
}
/// Spawn the stub server on an ephemeral port and return its URL + the
/// shared `seen_authorization` slot so tests can assert on it.
pub async fn spawn_stub() -> (String, Arc<Mutex<Option<String>>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
let stub = StubWfe::default();
let seen = stub.seen_authorization.clone();
let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
tokio::spawn(async move {
Server::builder()
.add_service(WfeServer::new(stub))
.serve_with_incoming(incoming)
.await
.unwrap();
});
(format!("http://{addr}"), seen)
}