cuenv_ci/stages/
onepassword.rs

1//! 1Password Stage Contributor
2//!
3//! Contributes 1Password WASM SDK setup task to the CI pipeline.
4//!
5//! This contributor self-detects when 1Password is needed by examining
6//! the pipeline's environment for `#OnePasswordRef` secrets.
7
8use super::StageContributor;
9use crate::ir::{BuildStage, IntermediateRepresentation, StageTask};
10use cuenv_core::environment::{Env, EnvValue, EnvValueSimple};
11use cuenv_core::manifest::Project;
12use std::collections::HashMap;
13
14/// 1Password stage contributor
15///
16/// Self-detects activation by checking if the pipeline's environment
17/// contains 1Password secret references (resolver="onepassword" or "op://" URIs).
18///
19/// When active, contributes:
20/// - Setup: Run `cuenv secrets setup onepassword` to initialize the WASM SDK
21#[derive(Debug, Clone, Copy, Default)]
22pub struct OnePasswordContributor;
23
24impl OnePasswordContributor {
25    /// Check if an environment contains 1Password secret references
26    fn environment_has_onepassword_refs(env: &Env, environment_name: &str) -> bool {
27        let env_vars = env.for_environment(environment_name);
28        env_vars.values().any(|value| match value {
29            EnvValue::String(s) => s.starts_with("op://"),
30            EnvValue::Secret(secret) => secret.resolver == "onepassword",
31            EnvValue::WithPolicies(with_policies) => match &with_policies.value {
32                EnvValueSimple::Secret(secret) => secret.resolver == "onepassword",
33                EnvValueSimple::String(s) => s.starts_with("op://"),
34                _ => false,
35            },
36            _ => false,
37        })
38    }
39}
40
41impl StageContributor for OnePasswordContributor {
42    fn id(&self) -> &'static str {
43        "1password"
44    }
45
46    fn is_active(&self, ir: &IntermediateRepresentation, project: &Project) -> bool {
47        // Self-detect: check if the pipeline's environment has 1Password refs
48        let Some(env_name) = &ir.pipeline.environment else {
49            return false;
50        };
51        let Some(env) = &project.env else {
52            return false;
53        };
54        Self::environment_has_onepassword_refs(env, env_name)
55    }
56
57    fn contribute(
58        &self,
59        ir: &IntermediateRepresentation,
60        _project: &Project,
61    ) -> (Vec<(BuildStage, StageTask)>, bool) {
62        // Idempotency: check if already contributed
63        if ir.stages.setup.iter().any(|t| t.id == "setup-1password") {
64            return (vec![], false);
65        }
66
67        let mut env = HashMap::new();
68        env.insert(
69            "OP_SERVICE_ACCOUNT_TOKEN".to_string(),
70            "${OP_SERVICE_ACCOUNT_TOKEN}".to_string(),
71        );
72
73        (
74            vec![(
75                BuildStage::Setup,
76                StageTask {
77                    id: "setup-1password".to_string(),
78                    provider: "1password".to_string(),
79                    label: Some("Setup 1Password".to_string()),
80                    command: vec!["cuenv secrets setup onepassword".to_string()],
81                    shell: false,
82                    env,
83                    // Depends on cuenv being installed/built
84                    depends_on: vec!["setup-cuenv".to_string()],
85                    priority: 20,
86                    ..Default::default()
87                },
88            )],
89            true,
90        )
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::ir::{PipelineMetadata, StageConfiguration};
98    use cuenv_core::secrets::Secret;
99
100    /// Create IR with a production environment set
101    fn make_ir_with_production_env() -> IntermediateRepresentation {
102        IntermediateRepresentation {
103            version: "1.4".to_string(),
104            pipeline: PipelineMetadata {
105                name: "test".to_string(),
106                environment: Some("production".to_string()),
107                requires_onepassword: false, // No longer used for detection
108                project_name: None,
109                trigger: None,
110            },
111            runtimes: vec![],
112            stages: StageConfiguration::default(),
113            tasks: vec![],
114        }
115    }
116
117    /// Create IR without any environment set
118    fn make_ir_without_env() -> IntermediateRepresentation {
119        IntermediateRepresentation {
120            version: "1.4".to_string(),
121            pipeline: PipelineMetadata {
122                name: "test".to_string(),
123                environment: None,
124                requires_onepassword: false,
125                project_name: None,
126                trigger: None,
127            },
128            runtimes: vec![],
129            stages: StageConfiguration::default(),
130            tasks: vec![],
131        }
132    }
133
134    /// Create project with 1Password secrets in production environment
135    fn make_project_with_onepassword_env() -> Project {
136        let mut env_overrides = HashMap::new();
137        let mut production_env = HashMap::new();
138        production_env.insert(
139            "CLOUDFLARE_API_TOKEN".to_string(),
140            EnvValue::Secret(Secret::onepassword("op://vault/item/field")),
141        );
142        env_overrides.insert("production".to_string(), production_env);
143
144        Project {
145            name: "test".to_string(),
146            env: Some(Env {
147                base: HashMap::new(),
148                environment: Some(env_overrides),
149            }),
150            ..Default::default()
151        }
152    }
153
154    /// Create project with op:// URI string in production environment
155    fn make_project_with_op_uri() -> Project {
156        let mut env_overrides = HashMap::new();
157        let mut production_env = HashMap::new();
158        production_env.insert(
159            "API_TOKEN".to_string(),
160            EnvValue::String("op://vault/item/field".to_string()),
161        );
162        env_overrides.insert("production".to_string(), production_env);
163
164        Project {
165            name: "test".to_string(),
166            env: Some(Env {
167                base: HashMap::new(),
168                environment: Some(env_overrides),
169            }),
170            ..Default::default()
171        }
172    }
173
174    /// Create project without 1Password secrets
175    fn make_project_without_onepassword() -> Project {
176        let mut env_overrides = HashMap::new();
177        let mut production_env = HashMap::new();
178        production_env.insert(
179            "SOME_VAR".to_string(),
180            EnvValue::String("value".to_string()),
181        );
182        env_overrides.insert("production".to_string(), production_env);
183
184        Project {
185            name: "test".to_string(),
186            env: Some(Env {
187                base: HashMap::new(),
188                environment: Some(env_overrides),
189            }),
190            ..Default::default()
191        }
192    }
193
194    /// Create project with no env configuration
195    fn make_project_without_env() -> Project {
196        Project {
197            name: "test".to_string(),
198            ..Default::default()
199        }
200    }
201
202    #[test]
203    fn test_is_active_with_onepassword_secret() {
204        let contributor = OnePasswordContributor;
205        let ir = make_ir_with_production_env();
206        let project = make_project_with_onepassword_env();
207
208        assert!(contributor.is_active(&ir, &project));
209    }
210
211    #[test]
212    fn test_is_active_with_op_uri() {
213        let contributor = OnePasswordContributor;
214        let ir = make_ir_with_production_env();
215        let project = make_project_with_op_uri();
216
217        assert!(contributor.is_active(&ir, &project));
218    }
219
220    #[test]
221    fn test_is_inactive_without_onepassword() {
222        let contributor = OnePasswordContributor;
223        let ir = make_ir_with_production_env();
224        let project = make_project_without_onepassword();
225
226        assert!(!contributor.is_active(&ir, &project));
227    }
228
229    #[test]
230    fn test_is_inactive_without_environment() {
231        let contributor = OnePasswordContributor;
232        let ir = make_ir_without_env();
233        let project = make_project_with_onepassword_env();
234
235        // No environment set on IR, so contributor is inactive
236        assert!(!contributor.is_active(&ir, &project));
237    }
238
239    #[test]
240    fn test_is_inactive_without_project_env() {
241        let contributor = OnePasswordContributor;
242        let ir = make_ir_with_production_env();
243        let project = make_project_without_env();
244
245        // Project has no env config, so contributor is inactive
246        assert!(!contributor.is_active(&ir, &project));
247    }
248
249    #[test]
250    fn test_contribute_returns_setup_task() {
251        let contributor = OnePasswordContributor;
252        let ir = make_ir_with_production_env();
253        let project = make_project_with_onepassword_env();
254
255        let (contributions, modified) = contributor.contribute(&ir, &project);
256
257        assert!(modified);
258        assert_eq!(contributions.len(), 1);
259
260        let (stage, task) = &contributions[0];
261        assert_eq!(*stage, BuildStage::Setup);
262        assert_eq!(task.id, "setup-1password");
263        assert_eq!(task.provider, "1password");
264        assert_eq!(task.priority, 20);
265    }
266
267    #[test]
268    fn test_contribute_sets_env_var() {
269        let contributor = OnePasswordContributor;
270        let ir = make_ir_with_production_env();
271        let project = make_project_with_onepassword_env();
272
273        let (contributions, _) = contributor.contribute(&ir, &project);
274        let (_, task) = &contributions[0];
275
276        assert!(task.env.contains_key("OP_SERVICE_ACCOUNT_TOKEN"));
277        assert_eq!(
278            task.env.get("OP_SERVICE_ACCOUNT_TOKEN").unwrap(),
279            "${OP_SERVICE_ACCOUNT_TOKEN}"
280        );
281    }
282
283    #[test]
284    fn test_contribute_runs_setup_command() {
285        let contributor = OnePasswordContributor;
286        let ir = make_ir_with_production_env();
287        let project = make_project_with_onepassword_env();
288
289        let (contributions, _) = contributor.contribute(&ir, &project);
290        let (_, task) = &contributions[0];
291
292        assert_eq!(task.command.len(), 1);
293        assert_eq!(task.command[0], "cuenv secrets setup onepassword");
294    }
295
296    #[test]
297    fn test_contribute_depends_on_setup_cuenv() {
298        let contributor = OnePasswordContributor;
299        let ir = make_ir_with_production_env();
300        let project = make_project_with_onepassword_env();
301
302        let (contributions, _) = contributor.contribute(&ir, &project);
303        let (_, task) = &contributions[0];
304
305        assert!(task.depends_on.contains(&"setup-cuenv".to_string()));
306    }
307
308    #[test]
309    fn test_contribute_is_idempotent() {
310        let contributor = OnePasswordContributor;
311        let mut ir = make_ir_with_production_env();
312        let project = make_project_with_onepassword_env();
313
314        // First contribution should modify
315        let (contributions, modified) = contributor.contribute(&ir, &project);
316        assert!(modified);
317        assert_eq!(contributions.len(), 1);
318
319        // Add the task to IR
320        for (stage, task) in contributions {
321            ir.stages.add(stage, task);
322        }
323
324        // Second contribution should not modify
325        let (contributions, modified) = contributor.contribute(&ir, &project);
326        assert!(!modified);
327        assert!(contributions.is_empty());
328    }
329}