greentic_component/cmd/
test.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use base64::Engine as _;
7use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
8use clap::{ArgAction, Args};
9use serde_json::Value;
10use uuid::Uuid;
11
12use crate::manifest::parse_manifest;
13use crate::test_harness::{HarnessConfig, TestHarness};
14use greentic_types::{EnvId, TeamId, TenantCtx, TenantId, UserId};
15
16#[derive(Args, Debug)]
17pub struct TestArgs {
18    /// Path to the component wasm binary.
19    #[arg(long, value_name = "PATH")]
20    pub wasm: PathBuf,
21    /// Optional manifest path (defaults to component.manifest.json next to the wasm).
22    #[arg(long, value_name = "PATH")]
23    pub manifest: Option<PathBuf>,
24    /// Operation to invoke (repeat for multi-step runs).
25    #[arg(long, value_name = "OP", action = ArgAction::Append)]
26    pub op: Vec<String>,
27    /// Input JSON file path (repeat for multi-step runs).
28    #[arg(long, value_name = "PATH", action = ArgAction::Append, conflicts_with = "input_json")]
29    pub input: Vec<PathBuf>,
30    /// Inline input JSON string (repeat for multi-step runs).
31    #[arg(long, value_name = "JSON", action = ArgAction::Append, conflicts_with = "input")]
32    pub input_json: Vec<String>,
33    /// Write output JSON to a file.
34    #[arg(long, value_name = "PATH")]
35    pub output: Option<PathBuf>,
36    /// Pretty-print JSON output.
37    #[arg(long)]
38    pub pretty: bool,
39    /// Dump in-memory state after invocation.
40    #[arg(long)]
41    pub state_dump: bool,
42    /// Seed in-memory state as KEY=BASE64 (repeatable).
43    #[arg(long = "state-set", value_name = "KEY=BASE64")]
44    pub state_set: Vec<String>,
45    /// Repeatable step marker for multi-step runs.
46    #[arg(long, action = ArgAction::Count)]
47    pub step: u8,
48    /// Load secrets from a .env style file.
49    #[arg(long, value_name = "PATH")]
50    pub secrets: Option<PathBuf>,
51    /// Load secrets from a JSON map file.
52    #[arg(long, value_name = "PATH")]
53    pub secrets_json: Option<PathBuf>,
54    /// Provide a secret inline as KEY=VALUE (repeatable).
55    #[arg(long = "secret", value_name = "KEY=VALUE")]
56    pub secret: Vec<String>,
57    /// Environment identifier for the exec context.
58    #[arg(long, default_value = "dev")]
59    pub env: String,
60    /// Tenant identifier for the exec context.
61    #[arg(long, default_value = "default")]
62    pub tenant: String,
63    /// Optional team identifier for the exec context.
64    #[arg(long)]
65    pub team: Option<String>,
66    /// Optional user identifier for the exec context.
67    #[arg(long)]
68    pub user: Option<String>,
69    /// Optional flow identifier for the exec context.
70    #[arg(long)]
71    pub flow: Option<String>,
72    /// Optional node identifier for the exec context.
73    #[arg(long)]
74    pub node: Option<String>,
75    /// Optional session identifier for the exec context.
76    #[arg(long)]
77    pub session: Option<String>,
78    /// Emit extra diagnostic output (e.g. generated session id).
79    #[arg(long)]
80    pub verbose: bool,
81}
82
83pub fn run(args: TestArgs) -> Result<()> {
84    let manifest_path = resolve_manifest_path(&args.wasm, args.manifest.as_deref())?;
85    let manifest_raw = fs::read_to_string(&manifest_path)
86        .with_context(|| format!("read manifest {}", manifest_path.display()))?;
87    let manifest_value: Value =
88        serde_json::from_str(&manifest_raw).context("manifest must be valid JSON")?;
89    let manifest = parse_manifest(&manifest_raw).context("parse manifest")?;
90
91    let steps = collect_steps(&args)?;
92    for (op, _) in &steps {
93        if !manifest
94            .operations
95            .iter()
96            .any(|operation| operation.name == *op)
97        {
98            bail!("operation `{op}` not declared in manifest");
99        }
100    }
101    let wasm_bytes =
102        fs::read(&args.wasm).with_context(|| format!("read wasm {}", args.wasm.display()))?;
103
104    let (tenant_ctx, session_id, generated_session) = build_tenant_ctx(&args)?;
105    if args.verbose && generated_session {
106        eprintln!("generated session id: {session_id}");
107    }
108
109    let (allow_state_read, allow_state_write, allow_state_delete) =
110        state_permissions(&manifest_value, &manifest);
111    if !args.state_set.is_empty() && !allow_state_write {
112        bail!("manifest does not declare host.state.write; add it to use --state-set");
113    }
114    let (allow_secrets, allowed_secrets) = secret_permissions(&manifest);
115
116    let secrets = load_secrets(&args)?;
117    if !allow_secrets && !secrets.is_empty() {
118        bail!("manifest does not declare host.secrets; add host.secrets to enable secrets access");
119    }
120
121    let state_seeds = parse_state_seeds(&args)?;
122    let prefix = state_prefix(args.flow.as_deref(), &session_id);
123    let flow_id = args.flow.clone().unwrap_or_else(|| "test".to_string());
124    let harness = TestHarness::new(HarnessConfig {
125        wasm_bytes,
126        tenant_ctx: tenant_ctx.clone(),
127        flow_id,
128        node_id: args.node.clone(),
129        state_prefix: prefix,
130        state_seeds,
131        allow_state_read,
132        allow_state_write,
133        allow_state_delete,
134        allow_secrets,
135        allowed_secrets,
136        secrets,
137    })?;
138
139    if steps.len() > 1 && args.output.is_some() {
140        bail!("--output is only supported for single-step runs");
141    }
142
143    for (idx, (op, input)) in steps.iter().enumerate() {
144        let output = harness.invoke(op, input)?;
145        let output = format_output(&output, args.pretty)?;
146        if let Some(path) = &args.output {
147            fs::write(path, output.as_bytes())
148                .with_context(|| format!("write output {}", path.display()))?;
149        }
150        if steps.len() > 1 {
151            println!("step {} output:\n{output}", idx + 1);
152        } else {
153            println!("{output}");
154        }
155    }
156
157    if args.state_dump {
158        let dump = harness.state_dump();
159        let dump_json = serde_json::to_string_pretty(&dump).unwrap_or_else(|_| "{}".into());
160        eprintln!("state dump:\n{dump_json}");
161    }
162
163    Ok(())
164}
165
166fn resolve_manifest_path(wasm: &Path, manifest: Option<&Path>) -> Result<PathBuf> {
167    if let Some(path) = manifest {
168        return Ok(path.to_path_buf());
169    }
170    let dir = wasm
171        .parent()
172        .ok_or_else(|| anyhow::anyhow!("wasm path has no parent directory"))?;
173    let candidate = dir.join("component.manifest.json");
174    if candidate.exists() {
175        Ok(candidate)
176    } else {
177        bail!(
178            "manifest not found; pass --manifest or place component.manifest.json next to the wasm"
179        );
180    }
181}
182
183fn collect_steps(args: &TestArgs) -> Result<Vec<(String, Value)>> {
184    if args.op.is_empty() {
185        bail!("--op is required");
186    }
187    let inputs = if !args.input.is_empty() {
188        let mut values = Vec::new();
189        for path in &args.input {
190            let raw = fs::read_to_string(path)
191                .with_context(|| format!("read input {}", path.display()))?;
192            values.push(serde_json::from_str(&raw).context("input file must be valid JSON")?);
193        }
194        values
195    } else if !args.input_json.is_empty() {
196        let mut values = Vec::new();
197        for raw in &args.input_json {
198            values.push(serde_json::from_str(raw).context("input-json must be valid JSON")?);
199        }
200        values
201    } else {
202        bail!("--input or --input-json is required");
203    };
204
205    if args.op.len() != inputs.len() {
206        bail!("provide the same number of --op and --input/--input-json values");
207    }
208    if args.op.len() > 1 {
209        let expected_steps = args.op.len().saturating_sub(1);
210        if args.step == 0 {
211            bail!("use --step to indicate a multi-step run");
212        }
213        if args.step as usize != expected_steps {
214            bail!(
215                "expected {expected_steps} --step flags for {} operations",
216                args.op.len()
217            );
218        }
219    }
220
221    Ok(args.op.clone().into_iter().zip(inputs).collect())
222}
223
224fn build_tenant_ctx(args: &TestArgs) -> Result<(TenantCtx, String, bool)> {
225    let env: EnvId = args.env.clone().try_into().context("invalid --env")?;
226    let tenant: TenantId = args.tenant.clone().try_into().context("invalid --tenant")?;
227    let mut ctx = TenantCtx::new(env, tenant);
228    if let Some(team) = &args.team {
229        let team: TeamId = team.clone().try_into().context("invalid --team")?;
230        ctx = ctx.with_team(Some(team));
231    }
232    if let Some(user) = &args.user {
233        let user: UserId = user.clone().try_into().context("invalid --user")?;
234        ctx = ctx.with_user(Some(user));
235    }
236
237    let (session_id, generated) = match &args.session {
238        Some(session) => (session.clone(), false),
239        None => (Uuid::new_v4().to_string(), true),
240    };
241    ctx = ctx.with_session(session_id.clone());
242
243    if let Some(flow) = &args.flow {
244        ctx = ctx.with_flow(flow.clone());
245    }
246    if let Some(node) = &args.node {
247        ctx = ctx.with_node(node.clone());
248    }
249
250    Ok((ctx, session_id, generated))
251}
252
253fn state_prefix(flow: Option<&str>, session: &str) -> String {
254    if let Some(flow) = flow {
255        format!("flow/{flow}/{session}")
256    } else {
257        format!("test/{session}")
258    }
259}
260
261fn state_permissions(
262    manifest_value: &Value,
263    manifest: &crate::manifest::ComponentManifest,
264) -> (bool, bool, bool) {
265    let mut allow_state_read = false;
266    let mut allow_state_write = false;
267    if let Some(state) = manifest.capabilities.host.state.as_ref() {
268        allow_state_read = state.read;
269        allow_state_write = state.write;
270    }
271    let allow_state_delete = manifest_value
272        .get("capabilities")
273        .and_then(|caps| caps.get("host"))
274        .and_then(|host| host.get("state"))
275        .and_then(|state| state.get("delete"))
276        .and_then(|value| value.as_bool())
277        .unwrap_or(false);
278    if allow_state_delete && !allow_state_write {
279        allow_state_write = true;
280    }
281    (allow_state_read, allow_state_write, allow_state_delete)
282}
283
284fn secret_permissions(manifest: &crate::manifest::ComponentManifest) -> (bool, HashSet<String>) {
285    let Some(secrets) = manifest.capabilities.host.secrets.as_ref() else {
286        return (false, HashSet::new());
287    };
288    let allowed = secrets
289        .required
290        .iter()
291        .map(|req| req.key.as_str().to_string())
292        .collect::<HashSet<_>>();
293    (true, allowed)
294}
295
296fn load_secrets(args: &TestArgs) -> Result<HashMap<String, String>> {
297    let mut secrets = HashMap::new();
298    if let Some(path) = &args.secrets {
299        let entries = parse_env_file(path)?;
300        secrets.extend(entries);
301    }
302    if let Some(path) = &args.secrets_json {
303        let entries = parse_json_secrets(path)?;
304        secrets.extend(entries);
305    }
306    for entry in &args.secret {
307        let (key, value) = entry
308            .split_once('=')
309            .ok_or_else(|| anyhow::anyhow!("invalid --secret `{entry}`; use KEY=VALUE"))?;
310        secrets.insert(key.to_string(), value.to_string());
311    }
312    Ok(secrets)
313}
314
315fn parse_state_seeds(args: &TestArgs) -> Result<Vec<(String, Vec<u8>)>> {
316    let mut seeds = Vec::new();
317    for entry in &args.state_set {
318        let (key, value) = entry
319            .split_once('=')
320            .ok_or_else(|| anyhow::anyhow!("invalid --state-set `{entry}`; use KEY=BASE64"))?;
321        let bytes = BASE64_STANDARD
322            .decode(value)
323            .with_context(|| format!("invalid base64 for state key `{key}`"))?;
324        seeds.push((key.to_string(), bytes));
325    }
326    Ok(seeds)
327}
328
329fn parse_env_file(path: &Path) -> Result<HashMap<String, String>> {
330    let contents =
331        fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
332    let mut secrets = HashMap::new();
333    for (idx, line) in contents.lines().enumerate() {
334        let line = line.trim();
335        if line.is_empty() || line.starts_with('#') {
336            continue;
337        }
338        let (key, value) = line.split_once('=').ok_or_else(|| {
339            anyhow::anyhow!(
340                "invalid secrets line {} in {} (expected KEY=VALUE)",
341                idx + 1,
342                path.display()
343            )
344        })?;
345        secrets.insert(key.trim().to_string(), value.trim().to_string());
346    }
347    Ok(secrets)
348}
349
350fn parse_json_secrets(path: &Path) -> Result<HashMap<String, String>> {
351    let contents =
352        fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
353    let value: Value = serde_json::from_str(&contents).context("secrets JSON must be valid")?;
354    let obj = value
355        .as_object()
356        .ok_or_else(|| anyhow::anyhow!("secrets JSON must be an object map"))?;
357    let mut secrets = HashMap::new();
358    for (key, value) in obj {
359        let value = value
360            .as_str()
361            .ok_or_else(|| anyhow::anyhow!("secret `{key}` must be a string value"))?;
362        secrets.insert(key.clone(), value.to_string());
363    }
364    Ok(secrets)
365}
366
367fn format_output(raw: &str, pretty: bool) -> Result<String> {
368    if !pretty {
369        return Ok(raw.to_string());
370    }
371    let value: Value = serde_json::from_str(raw).context("output is not valid JSON")?;
372    Ok(serde_json::to_string_pretty(&value)?)
373}