cloacina_workflow/context.rs
1/*
2 * Copyright 2025-2026 Colliery Software
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! # Minimal Context for Workflow Authoring
18//!
19//! This module provides a minimal `Context` type for sharing data between tasks.
20//! It contains only the core data operations without runtime-specific features
21//! like database persistence or dependency loading.
22
23use crate::error::ContextError;
24use crate::secret::{SecretAccessError, SecretResolver, SecretResolverError};
25use serde::{Deserialize, Serialize};
26use std::collections::{BTreeMap, HashMap};
27use std::fmt::Debug;
28use std::sync::Arc;
29use tracing::{debug, warn};
30
31/// A context that holds data for pipeline execution.
32///
33/// The context is a type-safe, serializable container that flows through your pipeline,
34/// allowing tasks to share data. It supports JSON serialization and provides key-value
35/// access patterns with comprehensive error handling.
36///
37/// ## Type Parameter
38///
39/// - `T`: The type of values stored in the context. Must implement `Serialize`, `Deserialize`, and `Debug`.
40///
41/// ## Examples
42///
43/// ```rust
44/// use cloacina_workflow::Context;
45/// use serde_json::Value;
46///
47/// // Create a context for JSON values
48/// let mut context = Context::<Value>::new();
49///
50/// // Insert and retrieve data
51/// context.insert("user_id", serde_json::json!(123)).unwrap();
52/// let user_id = context.get("user_id").unwrap();
53/// ```
54pub struct Context<T = serde_json::Value>
55where
56 T: Serialize + for<'de> Deserialize<'de> + Debug,
57{
58 data: HashMap<String, T>,
59
60 /// Secret resolution side channel (CLOACI-I-0133 / T-0858, design D-1).
61 ///
62 /// A runtime-only handle used by [`Context::secret`]. It is **never**
63 /// serialized: [`Context::to_json`] writes only `data`, and this field has
64 /// no `Serialize`/`Deserialize` — the moral equivalent of `#[serde(skip)]`.
65 /// That structural exclusion is exactly what keeps a resolved secret out of
66 /// the durable context / `schedules.params` / fires log (NFR-001). It is
67 /// likewise redacted from [`Debug`] below so it cannot leak through logs.
68 secrets: Option<Arc<dyn SecretResolver>>,
69}
70
71// Manual `Debug` (the struct can no longer derive it because
72// `Arc<dyn SecretResolver>` is not `Debug`). The resolver handle is redacted so
73// neither its address nor any backend state can leak through a `{:?}` render.
74impl<T> Debug for Context<T>
75where
76 T: Serialize + for<'de> Deserialize<'de> + Debug,
77{
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 f.debug_struct("Context")
80 .field("data", &self.data)
81 .field(
82 "secrets",
83 &self.secrets.as_ref().map(|_| "<redacted resolver>"),
84 )
85 .finish()
86 }
87}
88
89impl<T> Context<T>
90where
91 T: Serialize + for<'de> Deserialize<'de> + Debug,
92{
93 /// Creates a new empty context.
94 ///
95 /// # Examples
96 ///
97 /// ```rust
98 /// use cloacina_workflow::Context;
99 ///
100 /// let context = Context::<i32>::new();
101 /// assert!(context.get("any_key").is_none());
102 /// ```
103 pub fn new() -> Self {
104 debug!("Creating new empty context");
105 Self {
106 data: HashMap::new(),
107 secrets: None,
108 }
109 }
110
111 /// Creates a clone of this context's data.
112 ///
113 /// # Performance
114 ///
115 /// - Time complexity: O(n) where n is the number of key-value pairs
116 /// - Space complexity: O(n) for the cloned data
117 pub fn clone_data(&self) -> Self
118 where
119 T: Clone,
120 {
121 debug!("Cloning context data");
122 Self {
123 data: self.data.clone(),
124 // Carry the resolver handle (cheap Arc clone) so a cloned execution
125 // scope can still resolve secrets.
126 secrets: self.secrets.clone(),
127 }
128 }
129
130 /// Inserts a value into the context.
131 ///
132 /// # Arguments
133 ///
134 /// * `key` - The key to insert (can be any type that converts to String)
135 /// * `value` - The value to store
136 ///
137 /// # Returns
138 ///
139 /// * `Ok(())` - If the insertion was successful
140 /// * `Err(ContextError::KeyExists)` - If the key already exists
141 ///
142 /// # Examples
143 ///
144 /// ```rust
145 /// use cloacina_workflow::{Context, ContextError};
146 ///
147 /// let mut context = Context::<i32>::new();
148 ///
149 /// // First insertion succeeds
150 /// assert!(context.insert("count", 42).is_ok());
151 ///
152 /// // Duplicate insertion fails
153 /// assert!(matches!(context.insert("count", 43), Err(ContextError::KeyExists(_))));
154 /// ```
155 pub fn insert(&mut self, key: impl Into<String>, value: T) -> Result<(), ContextError> {
156 let key = key.into();
157 if self.data.contains_key(&key) {
158 warn!("Attempted to insert duplicate key: {}", key);
159 return Err(ContextError::KeyExists(key));
160 }
161 debug!("Inserting value for key: {}", key);
162 self.data.insert(key, value);
163 Ok(())
164 }
165
166 /// Updates an existing value in the context.
167 ///
168 /// # Arguments
169 ///
170 /// * `key` - The key to update
171 /// * `value` - The new value
172 ///
173 /// # Returns
174 ///
175 /// * `Ok(())` - If the update was successful
176 /// * `Err(ContextError::KeyNotFound)` - If the key doesn't exist
177 ///
178 /// # Examples
179 ///
180 /// ```rust
181 /// use cloacina_workflow::{Context, ContextError};
182 ///
183 /// let mut context = Context::<i32>::new();
184 /// context.insert("count", 42).unwrap();
185 ///
186 /// // Update existing key
187 /// assert!(context.update("count", 100).is_ok());
188 /// assert_eq!(context.get("count"), Some(&100));
189 ///
190 /// // Update non-existent key fails
191 /// assert!(matches!(context.update("missing", 1), Err(ContextError::KeyNotFound(_))));
192 /// ```
193 pub fn update(&mut self, key: impl Into<String>, value: T) -> Result<(), ContextError> {
194 let key = key.into();
195 if !self.data.contains_key(&key) {
196 warn!("Attempted to update non-existent key: {}", key);
197 return Err(ContextError::KeyNotFound(key));
198 }
199 debug!("Updating value for key: {}", key);
200 self.data.insert(key, value);
201 Ok(())
202 }
203
204 /// Gets a reference to a value from the context.
205 ///
206 /// # Arguments
207 ///
208 /// * `key` - The key to look up
209 ///
210 /// # Returns
211 ///
212 /// * `Some(&T)` - If the key exists
213 /// * `None` - If the key doesn't exist
214 ///
215 /// # Examples
216 ///
217 /// ```rust
218 /// use cloacina_workflow::Context;
219 ///
220 /// let mut context = Context::<String>::new();
221 /// context.insert("message", "Hello".to_string()).unwrap();
222 ///
223 /// assert_eq!(context.get("message"), Some(&"Hello".to_string()));
224 /// assert_eq!(context.get("missing"), None);
225 /// ```
226 pub fn get(&self, key: &str) -> Option<&T> {
227 debug!("Getting value for key: {}", key);
228 self.data.get(key)
229 }
230
231 /// Removes and returns a value from the context.
232 ///
233 /// # Arguments
234 ///
235 /// * `key` - The key to remove
236 ///
237 /// # Returns
238 ///
239 /// * `Some(T)` - If the key existed and was removed
240 /// * `None` - If the key didn't exist
241 ///
242 /// # Examples
243 ///
244 /// ```rust
245 /// use cloacina_workflow::Context;
246 ///
247 /// let mut context = Context::<i32>::new();
248 /// context.insert("temp", 42).unwrap();
249 ///
250 /// assert_eq!(context.remove("temp"), Some(42));
251 /// assert_eq!(context.get("temp"), None);
252 /// assert_eq!(context.remove("missing"), None);
253 /// ```
254 pub fn remove(&mut self, key: &str) -> Option<T> {
255 debug!("Removing value for key: {}", key);
256 self.data.remove(key)
257 }
258
259 /// Gets a reference to the underlying data HashMap.
260 ///
261 /// This method provides direct access to the internal data structure
262 /// for advanced use cases that need to iterate over all key-value pairs.
263 ///
264 /// # Returns
265 ///
266 /// A reference to the HashMap containing all context data
267 ///
268 /// # Examples
269 ///
270 /// ```rust
271 /// use cloacina_workflow::Context;
272 ///
273 /// let mut context = Context::<i32>::new();
274 /// context.insert("a", 1).unwrap();
275 /// context.insert("b", 2).unwrap();
276 ///
277 /// for (key, value) in context.data() {
278 /// println!("{}: {}", key, value);
279 /// }
280 /// ```
281 pub fn data(&self) -> &HashMap<String, T> {
282 &self.data
283 }
284
285 /// Consumes the context and returns the underlying data HashMap.
286 ///
287 /// # Returns
288 ///
289 /// The HashMap containing all context data
290 pub fn into_data(self) -> HashMap<String, T> {
291 self.data
292 }
293
294 /// Creates a Context from a HashMap.
295 ///
296 /// # Arguments
297 ///
298 /// * `data` - The HashMap to use as context data
299 ///
300 /// # Returns
301 ///
302 /// A new Context with the provided data
303 pub fn from_data(data: HashMap<String, T>) -> Self {
304 Self {
305 data,
306 secrets: None,
307 }
308 }
309
310 /// Serializes the context to a JSON string.
311 ///
312 /// # Returns
313 ///
314 /// * `Ok(String)` - The JSON representation of the context
315 /// * `Err(ContextError)` - If serialization fails
316 pub fn to_json(&self) -> Result<String, ContextError> {
317 debug!("Serializing context to JSON");
318 let json = serde_json::to_string(&self.data)?;
319 debug!("Context serialized successfully");
320 Ok(json)
321 }
322
323 /// Deserializes a context from a JSON string.
324 ///
325 /// # Arguments
326 ///
327 /// * `json` - The JSON string to deserialize
328 ///
329 /// # Returns
330 ///
331 /// * `Ok(Context<T>)` - The deserialized context
332 /// * `Err(ContextError)` - If deserialization fails
333 pub fn from_json(json: String) -> Result<Self, ContextError> {
334 debug!("Deserializing context from JSON");
335 let data = serde_json::from_str(&json)?;
336 debug!("Context deserialized successfully");
337 // A deserialized context is a durable snapshot: it never carries a
338 // resolver. The runtime re-attaches one at fire time if needed.
339 Ok(Self {
340 data,
341 secrets: None,
342 })
343 }
344
345 // ── Secret resolution side channel (CLOACI-I-0133 / T-0858, D-1) ─────────
346
347 /// Attach a secret resolver, builder-style.
348 ///
349 /// The resolver is a runtime-only side channel: it is NEVER serialized (see
350 /// [`Context::to_json`], which writes only `data`), which is what keeps a
351 /// resolved secret structurally out of the durable context.
352 pub fn with_secret_resolver(mut self, resolver: Arc<dyn SecretResolver>) -> Self {
353 self.secrets = Some(resolver);
354 self
355 }
356
357 /// Attach (or replace) the secret resolver on this scope.
358 pub fn set_secret_resolver(&mut self, resolver: Arc<dyn SecretResolver>) {
359 self.secrets = Some(resolver);
360 }
361
362 /// Whether a secret resolver is configured on this execution scope.
363 pub fn has_secret_resolver(&self) -> bool {
364 self.secrets.is_some()
365 }
366
367 /// Resolve a named secret into its decrypted `{field: value}` map.
368 ///
369 /// `name` may be either the concrete secret name OR a **declared local
370 /// binding name** that an instance mapped to a secret via a
371 /// `{"$secret": "..."}` reference (CLOACI-I-0133 / T-0859). When the
372 /// context carries a `{"$secret"}` alias map (under
373 /// [`secret::SECRET_REFS_KEY`](crate::secret::SECRET_REFS_KEY)) and `name`
374 /// appears as a local binding there, the mapped secret name is resolved
375 /// instead — so a task can read either the declared name it authored against
376 /// or the concrete secret the instance chose.
377 ///
378 /// The returned map is handed to the task and is NEVER inserted into the
379 /// context's serialized `data`. Errors clearly when no resolver is
380 /// configured ([`SecretAccessError::NotConfigured`]) or the name is absent
381 /// ([`SecretAccessError::NotFound`]).
382 pub async fn secret(&self, name: &str) -> Result<BTreeMap<String, String>, SecretAccessError> {
383 let resolver = self
384 .secrets
385 .as_ref()
386 .ok_or(SecretAccessError::NotConfigured)?;
387 let effective = self.resolve_secret_alias(name);
388 resolver.resolve(&effective).await.map_err(|e| match e {
389 SecretResolverError::NotFound(n) => SecretAccessError::NotFound(n),
390 SecretResolverError::NotGranted(n) => SecretAccessError::NotGranted(n),
391 SecretResolverError::Backend(m) => SecretAccessError::Backend(m),
392 })
393 }
394
395 /// Map a task-supplied secret name through the instance's `{"$secret"}` alias
396 /// map (if any), returning the concrete secret name to resolve.
397 ///
398 /// The alias map lives under [`secret::SECRET_REFS_KEY`] as a NAME→NAME
399 /// object of `local_binding_name -> secret_name`. It carries no secret
400 /// values, so reading it here cannot leak plaintext. A name absent from the
401 /// map (or the absence of a map) passes through unchanged.
402 fn resolve_secret_alias(&self, name: &str) -> String {
403 if let Some(v) = self.data.get(crate::secret::SECRET_REFS_KEY) {
404 // `T: Serialize`, so any backing value can be viewed as JSON; the map
405 // is a plain `{local: secret}` object of strings.
406 if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(v) {
407 if let Some(serde_json::Value::String(target)) = map.get(name) {
408 return target.clone();
409 }
410 }
411 }
412 name.to_string()
413 }
414
415 /// Resolve one field of a named secret.
416 ///
417 /// Convenience over [`Context::secret`]; errors with
418 /// [`SecretAccessError::FieldNotFound`] when the secret exists but lacks the
419 /// requested field.
420 pub async fn secret_field(&self, name: &str, field: &str) -> Result<String, SecretAccessError> {
421 let fields = self.secret(name).await?;
422 fields
423 .get(field)
424 .cloned()
425 .ok_or_else(|| SecretAccessError::FieldNotFound {
426 secret: name.to_string(),
427 field: field.to_string(),
428 })
429 }
430}
431
432/// Typed accessors for the task context (`Context<serde_json::Value>`).
433///
434/// Task bodies operate on a `Context<serde_json::Value>`, so reading an input
435/// otherwise means `get(...).and_then(|v| v.as_*()).ok_or_else(...)?` plus a
436/// `serde_json::from_value` round-trip, and writing means wrapping every value
437/// in `serde_json::json!(...)`. These helpers fold that boilerplate and return
438/// a [`TaskError`] so they compose with `?` in a task body (CLOACI-T-0733).
439///
440/// This mirrors the ergonomics Python authors already get from
441/// `context.get(key, default)` / `context.set(key, value)`.
442impl Context<serde_json::Value> {
443 /// Get a value by key and deserialize it into `V`.
444 ///
445 /// Returns `Ok(None)` when the key is absent, `Ok(Some(value))` when it is
446 /// present and deserializes cleanly, and `Err(TaskError::ValidationFailed)`
447 /// when the stored JSON does not match `V` (the message names the key and
448 /// target type).
449 ///
450 /// # Examples
451 ///
452 /// ```rust
453 /// use cloacina_workflow::Context;
454 ///
455 /// let mut ctx = Context::new();
456 /// ctx.insert("count", serde_json::json!(7)).unwrap();
457 /// let n: Option<i64> = ctx.get_as("count").unwrap();
458 /// assert_eq!(n, Some(7));
459 /// assert_eq!(ctx.get_as::<i64>("missing").unwrap(), None);
460 /// ```
461 pub fn get_as<V>(&self, key: &str) -> Result<Option<V>, crate::error::TaskError>
462 where
463 V: serde::de::DeserializeOwned,
464 {
465 match self.data.get(key) {
466 None => Ok(None),
467 Some(value) => serde_json::from_value(value.clone())
468 .map(Some)
469 .map_err(|e| crate::error::TaskError::ValidationFailed {
470 message: format!(
471 "context key '{}' could not be read as {}: {}",
472 key,
473 std::any::type_name::<V>(),
474 e
475 ),
476 }),
477 }
478 }
479
480 /// Get a value by key, deserialize it into `V`, and error if the key is
481 /// missing.
482 ///
483 /// `Err(TaskError::ValidationFailed)` when the key is absent or the stored
484 /// JSON does not match `V`.
485 ///
486 /// # Examples
487 ///
488 /// ```rust
489 /// use cloacina_workflow::Context;
490 ///
491 /// let mut ctx = Context::new();
492 /// ctx.insert("name", serde_json::json!("ada")).unwrap();
493 /// let name: String = ctx.get_required("name").unwrap();
494 /// assert_eq!(name, "ada");
495 /// assert!(ctx.get_required::<String>("missing").is_err());
496 /// ```
497 pub fn get_required<V>(&self, key: &str) -> Result<V, crate::error::TaskError>
498 where
499 V: serde::de::DeserializeOwned,
500 {
501 match self.get_as(key)? {
502 Some(value) => Ok(value),
503 None => Err(crate::error::TaskError::ValidationFailed {
504 message: format!(
505 "required context key '{}' is missing (expected {})",
506 key,
507 std::any::type_name::<V>()
508 ),
509 }),
510 }
511 }
512
513 /// Serialize a value and write it under `key`, **upserting** (insert or
514 /// overwrite).
515 ///
516 /// Folds the `serde_json::json!(...)` / `to_value` wrapping — and the
517 /// "exists? update : insert" dance — that every context write otherwise
518 /// repeats. Upsert semantics mirror Python's `context.set(key, value)`
519 /// (unlike the lower-level [`Context::insert`], which errors on an existing
520 /// key). Errors with `TaskError::ValidationFailed` only if the value cannot
521 /// be serialized.
522 ///
523 /// # Examples
524 ///
525 /// ```rust
526 /// use cloacina_workflow::Context;
527 ///
528 /// let mut ctx = Context::new();
529 /// ctx.insert_as("total", 42u32).unwrap();
530 /// assert_eq!(ctx.get_as::<u32>("total").unwrap(), Some(42));
531 /// // Upserts — overwriting an existing key is fine.
532 /// ctx.insert_as("total", 100u32).unwrap();
533 /// assert_eq!(ctx.get_as::<u32>("total").unwrap(), Some(100));
534 /// ```
535 pub fn insert_as<V>(
536 &mut self,
537 key: impl Into<String>,
538 value: V,
539 ) -> Result<(), crate::error::TaskError>
540 where
541 V: serde::Serialize,
542 {
543 let key = key.into();
544 let json =
545 serde_json::to_value(value).map_err(|e| crate::error::TaskError::ValidationFailed {
546 message: format!("context key '{}' could not be serialized: {}", key, e),
547 })?;
548 // Upsert: overwrite if present, insert otherwise.
549 self.data.insert(key, json);
550 Ok(())
551 }
552}
553
554impl<T> Default for Context<T>
555where
556 T: Serialize + for<'de> Deserialize<'de> + Debug,
557{
558 fn default() -> Self {
559 Self::new()
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 fn setup_test_context() -> Context<i32> {
568 Context::new()
569 }
570
571 #[test]
572 fn test_context_operations() {
573 let mut context = setup_test_context();
574
575 // Test empty context
576 assert!(context.data.is_empty());
577
578 // Test insert and get
579 context.insert("test", 42).unwrap();
580 assert_eq!(context.get("test"), Some(&42));
581
582 // Test duplicate insert fails
583 assert!(matches!(
584 context.insert("test", 43),
585 Err(ContextError::KeyExists(_))
586 ));
587
588 // Test update
589 context.update("test", 43).unwrap();
590 assert_eq!(context.get("test"), Some(&43));
591
592 // Test update nonexistent key fails
593 assert!(matches!(
594 context.update("nonexistent", 42),
595 Err(ContextError::KeyNotFound(_))
596 ));
597 }
598
599 #[test]
600 fn test_context_serialization() {
601 let mut context = setup_test_context();
602 context.insert("test", 42).unwrap();
603
604 let json = context.to_json().unwrap();
605 let deserialized = Context::<i32>::from_json(json).unwrap();
606
607 assert_eq!(deserialized.get("test"), Some(&42));
608 }
609
610 #[test]
611 fn test_context_clone_data() {
612 let mut context = Context::<i32>::new();
613 context.insert("a", 1).unwrap();
614 context.insert("b", 2).unwrap();
615
616 let cloned = context.clone_data();
617 assert_eq!(cloned.get("a"), Some(&1));
618 assert_eq!(cloned.get("b"), Some(&2));
619 }
620
621 #[test]
622 fn test_context_from_data() {
623 let mut data = HashMap::new();
624 data.insert("key".to_string(), 42);
625
626 let context = Context::from_data(data);
627 assert_eq!(context.get("key"), Some(&42));
628 }
629
630 #[test]
631 fn test_context_into_data() {
632 let mut context = Context::<i32>::new();
633 context.insert("key", 42).unwrap();
634
635 let data = context.into_data();
636 assert_eq!(data.get("key"), Some(&42));
637 }
638
639 // CLOACI-T-0733: typed accessors on Context<serde_json::Value>.
640 #[test]
641 fn test_typed_accessors_roundtrip() {
642 let mut ctx = Context::new();
643 ctx.insert_as("count", 7u32).unwrap();
644 ctx.insert_as("name", "ada").unwrap();
645
646 // get_as: present + absent
647 assert_eq!(ctx.get_as::<u32>("count").unwrap(), Some(7));
648 assert_eq!(ctx.get_as::<String>("missing").unwrap(), None);
649
650 // get_required: present
651 let name: String = ctx.get_required("name").unwrap();
652 assert_eq!(name, "ada");
653
654 // insert_as upserts (overwrites) without erroring
655 ctx.insert_as("count", 100u32).unwrap();
656 assert_eq!(ctx.get_as::<u32>("count").unwrap(), Some(100));
657 }
658
659 // CLOACI-T-0858: secret resolution side channel.
660 // (`SecretResolver`, `SecretResolverError`, `SecretAccessError`, `Arc`,
661 // `BTreeMap`, `HashMap` all come in via `use super::*`.)
662 use async_trait::async_trait;
663
664 /// A stub resolver holding an in-memory `{name -> {field: value}}` map.
665 struct StubResolver {
666 secrets: HashMap<String, BTreeMap<String, String>>,
667 }
668
669 #[async_trait]
670 impl SecretResolver for StubResolver {
671 async fn resolve(
672 &self,
673 name: &str,
674 ) -> Result<BTreeMap<String, String>, SecretResolverError> {
675 self.secrets
676 .get(name)
677 .cloned()
678 .ok_or_else(|| SecretResolverError::NotFound(name.to_string()))
679 }
680 }
681
682 fn stub_resolver() -> Arc<dyn SecretResolver> {
683 let mut db = BTreeMap::new();
684 db.insert("host".to_string(), "db.internal".to_string());
685 db.insert("password".to_string(), "s3cr3t-p@ss".to_string());
686 let mut secrets = HashMap::new();
687 secrets.insert("db_prod".to_string(), db);
688 Arc::new(StubResolver { secrets })
689 }
690
691 #[test]
692 fn test_secret_resolver_field_is_not_serialized() {
693 // A Context carrying a resolver must serialize to exactly the same JSON
694 // as one without: the resolver is structurally outside `data`.
695 let mut ctx = Context::<serde_json::Value>::new();
696 ctx.insert("visible", serde_json::json!("in-context"))
697 .unwrap();
698 ctx.set_secret_resolver(stub_resolver());
699 assert!(ctx.has_secret_resolver());
700
701 let json = ctx.to_json().unwrap();
702 // The serialized form is just the data map — no resolver, no plaintext.
703 assert!(json.contains("visible"));
704 assert!(
705 !json.contains("s3cr3t-p@ss"),
706 "secret leaked into serialized Context: {json}"
707 );
708 assert!(!json.contains("secrets"));
709 assert!(!json.contains("resolver"));
710
711 // Round-tripping drops the resolver (a durable snapshot never carries one).
712 let restored = Context::<serde_json::Value>::from_json(json).unwrap();
713 assert!(!restored.has_secret_resolver());
714 }
715
716 #[test]
717 fn test_debug_redacts_resolver_and_never_prints_plaintext() {
718 let mut ctx = Context::<serde_json::Value>::new();
719 ctx.set_secret_resolver(stub_resolver());
720 let dbg = format!("{:?}", ctx);
721 assert!(dbg.contains("<redacted resolver>"), "debug: {dbg}");
722 assert!(
723 !dbg.contains("s3cr3t-p@ss"),
724 "secret leaked into Debug: {dbg}"
725 );
726 }
727
728 #[tokio::test]
729 async fn test_secret_accessor_not_configured_errors_clearly() {
730 let ctx = Context::<serde_json::Value>::new();
731 let err = ctx.secret("db_prod").await.unwrap_err();
732 assert!(matches!(err, SecretAccessError::NotConfigured));
733 }
734
735 #[tokio::test]
736 async fn test_secret_accessor_happy_path() {
737 let ctx = Context::<serde_json::Value>::new().with_secret_resolver(stub_resolver());
738 let fields = ctx.secret("db_prod").await.unwrap();
739 assert_eq!(fields.get("password").unwrap(), "s3cr3t-p@ss");
740 assert_eq!(
741 ctx.secret_field("db_prod", "host").await.unwrap(),
742 "db.internal"
743 );
744 }
745
746 // CLOACI-T-0859: the `{"$secret"}` alias map redirects a task's declared
747 // local binding name to the concrete secret the instance chose.
748 #[tokio::test]
749 async fn test_secret_accessor_resolves_through_alias_map() {
750 let mut ctx = Context::<serde_json::Value>::new().with_secret_resolver(stub_resolver());
751 // The instance mapped the declared name `dst` → concrete secret `db_prod`.
752 ctx.insert(
753 crate::secret::SECRET_REFS_KEY,
754 serde_json::json!({"dst": "db_prod"}),
755 )
756 .unwrap();
757
758 // Reading via the DECLARED local binding name resolves the mapped secret.
759 let fields = ctx.secret("dst").await.unwrap();
760 assert_eq!(fields.get("password").unwrap(), "s3cr3t-p@ss");
761 assert_eq!(
762 ctx.secret_field("dst", "host").await.unwrap(),
763 "db.internal"
764 );
765
766 // Reading via the concrete secret name still works (no alias → passthrough).
767 assert_eq!(
768 ctx.secret("db_prod")
769 .await
770 .unwrap()
771 .get("password")
772 .unwrap(),
773 "s3cr3t-p@ss"
774 );
775
776 // A name with no alias and no matching secret is NotFound.
777 assert!(matches!(
778 ctx.secret("unmapped").await.unwrap_err(),
779 SecretAccessError::NotFound(_)
780 ));
781
782 // The resolved plaintext never entered the serialized context.
783 let json = ctx.to_json().unwrap();
784 assert!(!json.contains("s3cr3t-p@ss"), "secret leaked: {json}");
785 }
786
787 #[tokio::test]
788 async fn test_secret_accessor_missing_name_and_field() {
789 let ctx = Context::<serde_json::Value>::new().with_secret_resolver(stub_resolver());
790 assert!(matches!(
791 ctx.secret("absent").await.unwrap_err(),
792 SecretAccessError::NotFound(_)
793 ));
794 assert!(matches!(
795 ctx.secret_field("db_prod", "absent_field")
796 .await
797 .unwrap_err(),
798 SecretAccessError::FieldNotFound { .. }
799 ));
800 }
801
802 #[test]
803 fn test_typed_accessor_errors_are_actionable() {
804 let mut ctx = Context::new();
805 ctx.insert("count", serde_json::json!("not-a-number"))
806 .unwrap();
807
808 // Type mismatch names the key and target type.
809 let err = ctx.get_as::<u32>("count").unwrap_err();
810 let msg = err.to_string();
811 assert!(msg.contains("count"), "msg should name the key: {msg}");
812
813 // Missing required key errors and names the key.
814 let err = ctx.get_required::<u32>("absent").unwrap_err();
815 let msg = err.to_string();
816 assert!(msg.contains("absent"), "msg should name the key: {msg}");
817 }
818}