Skip to main content

dsp_cli/actions/auth/
logout.rs

1//! Actions for `dsp auth logout`.
2//!
3//! Removes the cached token for a server. If no token is cached, reports that
4//! gracefully without error — logout is idempotent.
5
6use std::path::Path;
7
8use crate::cli::LogoutArgs;
9use crate::config::{AuthCache, Config};
10use crate::diagnostic::Diagnostic;
11use crate::render::auth::AuthLogoutOutcome;
12use crate::render::{MetaContext, Renderer};
13
14/// Log out from a DSP server.
15///
16/// Loads the auth cache, removes the server entry if present, persists the
17/// change, and renders the outcome. Always returns `Ok(())`.
18pub fn run(args: &LogoutArgs, cfg: &Config, renderer: &mut dyn Renderer) -> Result<(), Diagnostic> {
19    run_impl(args, cfg, renderer, None)
20}
21
22fn run_impl(
23    _args: &LogoutArgs,
24    cfg: &Config,
25    renderer: &mut dyn Renderer,
26    cache_path: Option<&Path>,
27) -> Result<(), Diagnostic> {
28    let mut cache = match cache_path {
29        Some(p) => AuthCache::load_from(p)?,
30        None => AuthCache::load()?,
31    };
32
33    let was_cached = cache.remove(&cfg.server);
34
35    // Only write to disk if there was actually something to remove —
36    // avoid creating a cache file where none existed before.
37    if was_cached {
38        match cache_path {
39            Some(p) => cache.save_to(p)?,
40            None => cache.save()?,
41        }
42    }
43
44    // After logout no token remains → "anonymous" per ADR-0007 vocabulary.
45    // Route through the shared helper for consistency with every other command;
46    // `cache` has had this server's entry removed above, so it resolves to
47    // "anonymous" regardless.
48    let meta = MetaContext {
49        server_label: cfg.server.clone(),
50        auth_state: crate::actions::auth_state::read_auth_state(None, &cache, &cfg.server),
51        filter_warning: None,
52        count_caveat: None,
53    };
54
55    let outcome = AuthLogoutOutcome {
56        server: cfg.server.clone(),
57        was_cached,
58    };
59
60    renderer.auth_logout(&outcome, &meta)?;
61    Ok(())
62}
63
64#[cfg(test)]
65mod tests {
66    use tempfile::TempDir;
67
68    use super::run_impl;
69    use crate::cli::{FormatArgs, LogoutArgs};
70    use crate::config::auth_cache::ServerEntry;
71    use crate::config::{AuthCache, Config};
72    use crate::diagnostic::Diagnostic;
73    use crate::render::auth::{
74        AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
75    };
76    use crate::render::{Format, MetaContext, Renderer};
77
78    // ── recording renderer ────────────────────────────────────────────────────
79
80    struct RecordingRenderer {
81        logout_outcome: Option<(String, bool)>,
82        logout_auth_state: Option<String>,
83    }
84
85    impl RecordingRenderer {
86        fn new() -> Self {
87            Self {
88                logout_outcome: None,
89                logout_auth_state: None,
90            }
91        }
92    }
93
94    impl Renderer for RecordingRenderer {
95        fn diagnostic(
96            &mut self,
97            _diag: &Diagnostic,
98            _meta: &MetaContext,
99        ) -> Result<(), Diagnostic> {
100            Ok(())
101        }
102
103        fn auth_login(
104            &mut self,
105            _outcome: &AuthLoginOutcome,
106            _meta: &MetaContext,
107        ) -> Result<(), Diagnostic> {
108            Ok(())
109        }
110
111        fn auth_status(
112            &mut self,
113            _outcome: &AuthStatusOutcome,
114            _meta: &MetaContext,
115        ) -> Result<(), Diagnostic> {
116            Ok(())
117        }
118
119        fn auth_logout(
120            &mut self,
121            outcome: &AuthLogoutOutcome,
122            meta: &MetaContext,
123        ) -> Result<(), Diagnostic> {
124            self.logout_outcome = Some((outcome.server.clone(), outcome.was_cached));
125            self.logout_auth_state = Some(meta.auth_state.clone());
126            Ok(())
127        }
128
129        fn auth_set_token(
130            &mut self,
131            _outcome: &AuthSetTokenOutcome,
132            _meta: &MetaContext,
133        ) -> Result<(), Diagnostic> {
134            Ok(())
135        }
136
137        fn project_dump(
138            &mut self,
139            _outcome: &crate::render::DumpOutcome,
140            _meta: &MetaContext,
141        ) -> Result<(), Diagnostic> {
142            Ok(())
143        }
144
145        fn project_dump_deleted(
146            &mut self,
147            _outcome: &crate::render::DumpDeleteOutcome,
148            _meta: &MetaContext,
149        ) -> Result<(), Diagnostic> {
150            Ok(())
151        }
152
153        fn projects(
154            &mut self,
155            _view: &crate::render::ProjectListView,
156            _meta: &MetaContext,
157        ) -> Result<(), Diagnostic> {
158            Ok(())
159        }
160
161        fn project_describe(
162            &mut self,
163            _project: &crate::model::ProjectDetail,
164            _meta: &MetaContext,
165        ) -> Result<(), Diagnostic> {
166            Ok(())
167        }
168
169        fn data_models(
170            &mut self,
171            _view: &crate::render::DataModelListView,
172            _meta: &MetaContext,
173        ) -> Result<(), Diagnostic> {
174            Ok(())
175        }
176
177        fn data_model_describe(
178            &mut self,
179            _detail: &crate::model::DataModelDetail,
180            _meta: &MetaContext,
181        ) -> Result<(), Diagnostic> {
182            Ok(())
183        }
184
185        fn resource_types(
186            &mut self,
187            _view: &crate::render::ResourceTypeListView,
188            _meta: &MetaContext,
189        ) -> Result<(), Diagnostic> {
190            Ok(())
191        }
192
193        fn resource_type_describe(
194            &mut self,
195            _detail: &crate::model::ResourceTypeDetail,
196            _meta: &MetaContext,
197        ) -> Result<(), Diagnostic> {
198            unimplemented!("resource_type_describe not used in logout tests")
199        }
200
201        fn data_model_structure(
202            &mut self,
203            _structure: &crate::model::DataModelStructure,
204            _meta: &MetaContext,
205        ) -> Result<(), Diagnostic> {
206            unimplemented!("data_model_structure not used in logout tests")
207        }
208
209        fn resources(
210            &mut self,
211            _view: &crate::render::ResourceListView,
212            _meta: &MetaContext,
213        ) -> Result<(), Diagnostic> {
214            Ok(())
215        }
216
217        fn resource_describe(
218            &mut self,
219            _detail: &crate::model::ResourceDetail,
220            _meta: &MetaContext,
221        ) -> Result<(), Diagnostic> {
222            Ok(())
223        }
224    }
225
226    // ── helpers ───────────────────────────────────────────────────────────────
227
228    fn make_args(server: &str) -> (LogoutArgs, Config) {
229        let args = LogoutArgs {
230            server: Some(server.to_string()),
231            format: FormatArgs {
232                format: Format::Prose,
233                json: false,
234                lines: false,
235                columns: None,
236                no_header: false,
237                header_only: false,
238            },
239        };
240        let cfg = Config {
241            server: server.to_string(),
242        };
243        (args, cfg)
244    }
245
246    // ── tests ─────────────────────────────────────────────────────────────────
247
248    #[test]
249    fn logout_removes_entry_and_reports_was_cached() {
250        let dir = TempDir::new().unwrap();
251        let cache_path = dir.path().join("auth.toml");
252        let (args, cfg) = make_args("https://api.test.dasch.swiss");
253
254        // Pre-populate cache.
255        let mut cache = AuthCache::default();
256        cache.set_entry(
257            "https://api.test.dasch.swiss",
258            ServerEntry {
259                token: "tok-abc".to_string(),
260                user: Some("u@x.test".to_string()),
261                acquired_at: None,
262                expires_at: None,
263            },
264        );
265        cache.save_to(&cache_path).unwrap();
266
267        let mut renderer = RecordingRenderer::new();
268        run_impl(&args, &cfg, &mut renderer, Some(&cache_path)).unwrap();
269
270        // Renderer must report was_cached = true.
271        let (server, was_cached) = renderer.logout_outcome.unwrap();
272        assert_eq!(server, "https://api.test.dasch.swiss");
273        assert!(was_cached, "expected was_cached=true when entry existed");
274
275        // Entry must actually be gone.
276        let loaded = AuthCache::load_from(&cache_path).unwrap();
277        assert_eq!(loaded.token("https://api.test.dasch.swiss"), None);
278    }
279
280    #[test]
281    fn logout_on_empty_cache_reports_was_cached_false() {
282        let dir = TempDir::new().unwrap();
283        let cache_path = dir.path().join("auth.toml");
284        let (args, cfg) = make_args("https://api.test.dasch.swiss");
285
286        let mut renderer = RecordingRenderer::new();
287        run_impl(&args, &cfg, &mut renderer, Some(&cache_path)).unwrap();
288
289        let (_, was_cached) = renderer.logout_outcome.unwrap();
290        assert!(
291            !was_cached,
292            "expected was_cached=false when cache was empty"
293        );
294    }
295
296    #[test]
297    fn logout_on_empty_cache_does_not_create_cache_file() {
298        let dir = TempDir::new().unwrap();
299        let cache_path = dir.path().join("auth.toml");
300        let (args, cfg) = make_args("https://api.test.dasch.swiss");
301
302        let mut renderer = RecordingRenderer::new();
303        run_impl(&args, &cfg, &mut renderer, Some(&cache_path)).unwrap();
304
305        assert!(
306            !cache_path.exists(),
307            "logout on empty cache must not create the cache file"
308        );
309    }
310
311    #[test]
312    fn run_always_returns_ok() {
313        let dir = TempDir::new().unwrap();
314        let cache_path = dir.path().join("auth.toml");
315        let (args, cfg) = make_args("https://api.test.dasch.swiss");
316
317        let mut renderer = RecordingRenderer::new();
318        let result = run_impl(&args, &cfg, &mut renderer, Some(&cache_path));
319        assert!(
320            result.is_ok(),
321            "logout should always return Ok; got {result:?}"
322        );
323    }
324
325    #[test]
326    fn logout_meta_auth_is_anonymous() {
327        // Guards the ADR-0007 harmonization: after logout, _meta.auth must be
328        // "anonymous" (no token present), not the old "not_logged_in" string.
329        let dir = TempDir::new().unwrap();
330        let cache_path = dir.path().join("auth.toml");
331        let (args, cfg) = make_args("https://api.test.dasch.swiss");
332
333        let mut renderer = RecordingRenderer::new();
334        run_impl(&args, &cfg, &mut renderer, Some(&cache_path)).unwrap();
335
336        assert_eq!(
337            renderer.logout_auth_state.as_deref(),
338            Some("anonymous"),
339            "_meta.auth after logout must be 'anonymous' (ADR-0007 vocabulary)"
340        );
341    }
342}