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        count_cost: None,
54    };
55
56    let outcome = AuthLogoutOutcome {
57        server: cfg.server.clone(),
58        was_cached,
59    };
60
61    renderer.auth_logout(&outcome, &meta)?;
62    Ok(())
63}
64
65#[cfg(test)]
66mod tests {
67    use tempfile::TempDir;
68
69    use super::run_impl;
70    use crate::cli::{FormatArgs, LogoutArgs};
71    use crate::config::auth_cache::ServerEntry;
72    use crate::config::{AuthCache, Config};
73    use crate::diagnostic::Diagnostic;
74    use crate::render::auth::{
75        AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
76    };
77    use crate::render::{Format, MetaContext, Renderer};
78
79    // ── recording renderer ────────────────────────────────────────────────────
80
81    struct RecordingRenderer {
82        logout_outcome: Option<(String, bool)>,
83        logout_auth_state: Option<String>,
84    }
85
86    impl RecordingRenderer {
87        fn new() -> Self {
88            Self {
89                logout_outcome: None,
90                logout_auth_state: None,
91            }
92        }
93    }
94
95    impl Renderer for RecordingRenderer {
96        fn diagnostic(
97            &mut self,
98            _diag: &Diagnostic,
99            _meta: &MetaContext,
100        ) -> Result<(), Diagnostic> {
101            Ok(())
102        }
103
104        fn auth_login(
105            &mut self,
106            _outcome: &AuthLoginOutcome,
107            _meta: &MetaContext,
108        ) -> Result<(), Diagnostic> {
109            Ok(())
110        }
111
112        fn auth_status(
113            &mut self,
114            _outcome: &AuthStatusOutcome,
115            _meta: &MetaContext,
116        ) -> Result<(), Diagnostic> {
117            Ok(())
118        }
119
120        fn auth_logout(
121            &mut self,
122            outcome: &AuthLogoutOutcome,
123            meta: &MetaContext,
124        ) -> Result<(), Diagnostic> {
125            self.logout_outcome = Some((outcome.server.clone(), outcome.was_cached));
126            self.logout_auth_state = Some(meta.auth_state.clone());
127            Ok(())
128        }
129
130        fn auth_set_token(
131            &mut self,
132            _outcome: &AuthSetTokenOutcome,
133            _meta: &MetaContext,
134        ) -> Result<(), Diagnostic> {
135            Ok(())
136        }
137
138        fn project_dump(
139            &mut self,
140            _outcome: &crate::render::DumpOutcome,
141            _meta: &MetaContext,
142        ) -> Result<(), Diagnostic> {
143            Ok(())
144        }
145
146        fn project_dump_deleted(
147            &mut self,
148            _outcome: &crate::render::DumpDeleteOutcome,
149            _meta: &MetaContext,
150        ) -> Result<(), Diagnostic> {
151            Ok(())
152        }
153
154        fn projects(
155            &mut self,
156            _view: &crate::render::ProjectListView,
157            _meta: &MetaContext,
158        ) -> Result<(), Diagnostic> {
159            Ok(())
160        }
161
162        fn project_describe(
163            &mut self,
164            _project: &crate::model::ProjectDetail,
165            _meta: &MetaContext,
166        ) -> Result<(), Diagnostic> {
167            Ok(())
168        }
169
170        fn data_models(
171            &mut self,
172            _view: &crate::render::DataModelListView,
173            _meta: &MetaContext,
174        ) -> Result<(), Diagnostic> {
175            Ok(())
176        }
177
178        fn data_model_describe(
179            &mut self,
180            _detail: &crate::model::DataModelDetail,
181            _meta: &MetaContext,
182        ) -> Result<(), Diagnostic> {
183            Ok(())
184        }
185
186        fn resource_types(
187            &mut self,
188            _view: &crate::render::ResourceTypeListView,
189            _meta: &MetaContext,
190        ) -> Result<(), Diagnostic> {
191            Ok(())
192        }
193
194        fn resource_type_describe(
195            &mut self,
196            _detail: &crate::model::ResourceTypeDetail,
197            _meta: &MetaContext,
198        ) -> Result<(), Diagnostic> {
199            unimplemented!("resource_type_describe not used in logout tests")
200        }
201
202        fn data_model_structure(
203            &mut self,
204            _structure: &crate::model::DataModelStructure,
205            _meta: &MetaContext,
206        ) -> Result<(), Diagnostic> {
207            unimplemented!("data_model_structure not used in logout tests")
208        }
209
210        fn resources(
211            &mut self,
212            _view: &crate::render::ResourceListView,
213            _meta: &MetaContext,
214        ) -> Result<(), Diagnostic> {
215            Ok(())
216        }
217
218        fn resource_describe(
219            &mut self,
220            _detail: &crate::model::ResourceDetail,
221            _meta: &MetaContext,
222        ) -> Result<(), Diagnostic> {
223            Ok(())
224        }
225
226        fn vocabularies(
227            &mut self,
228            _view: &crate::render::VocabularyListView,
229            _meta: &MetaContext,
230        ) -> Result<(), Diagnostic> {
231            unimplemented!("not exercised by this file's tests")
232        }
233
234        fn vocabulary_describe(
235            &mut self,
236            _detail: &crate::model::VocabularyDetail,
237            _meta: &MetaContext,
238        ) -> Result<(), Diagnostic> {
239            unimplemented!("not exercised by this file's tests")
240        }
241    }
242
243    // ── helpers ───────────────────────────────────────────────────────────────
244
245    fn make_args(server: &str) -> (LogoutArgs, Config) {
246        let args = LogoutArgs {
247            server: Some(server.to_string()),
248            format: FormatArgs {
249                format: Format::Prose,
250                json: false,
251                lines: false,
252                columns: None,
253                no_header: false,
254                header_only: false,
255            },
256        };
257        let cfg = Config {
258            server: server.to_string(),
259        };
260        (args, cfg)
261    }
262
263    // ── tests ─────────────────────────────────────────────────────────────────
264
265    #[test]
266    fn logout_removes_entry_and_reports_was_cached() {
267        let dir = TempDir::new().unwrap();
268        let cache_path = dir.path().join("auth.toml");
269        let (args, cfg) = make_args("https://api.test.dasch.swiss");
270
271        // Pre-populate cache.
272        let mut cache = AuthCache::default();
273        cache.set_entry(
274            "https://api.test.dasch.swiss",
275            ServerEntry {
276                token: "tok-abc".to_string(),
277                user: Some("u@x.test".to_string()),
278                acquired_at: None,
279                expires_at: None,
280            },
281        );
282        cache.save_to(&cache_path).unwrap();
283
284        let mut renderer = RecordingRenderer::new();
285        run_impl(&args, &cfg, &mut renderer, Some(&cache_path)).unwrap();
286
287        // Renderer must report was_cached = true.
288        let (server, was_cached) = renderer.logout_outcome.unwrap();
289        assert_eq!(server, "https://api.test.dasch.swiss");
290        assert!(was_cached, "expected was_cached=true when entry existed");
291
292        // Entry must actually be gone.
293        let loaded = AuthCache::load_from(&cache_path).unwrap();
294        assert_eq!(loaded.token("https://api.test.dasch.swiss"), None);
295    }
296
297    #[test]
298    fn logout_on_empty_cache_reports_was_cached_false() {
299        let dir = TempDir::new().unwrap();
300        let cache_path = dir.path().join("auth.toml");
301        let (args, cfg) = make_args("https://api.test.dasch.swiss");
302
303        let mut renderer = RecordingRenderer::new();
304        run_impl(&args, &cfg, &mut renderer, Some(&cache_path)).unwrap();
305
306        let (_, was_cached) = renderer.logout_outcome.unwrap();
307        assert!(
308            !was_cached,
309            "expected was_cached=false when cache was empty"
310        );
311    }
312
313    #[test]
314    fn logout_on_empty_cache_does_not_create_cache_file() {
315        let dir = TempDir::new().unwrap();
316        let cache_path = dir.path().join("auth.toml");
317        let (args, cfg) = make_args("https://api.test.dasch.swiss");
318
319        let mut renderer = RecordingRenderer::new();
320        run_impl(&args, &cfg, &mut renderer, Some(&cache_path)).unwrap();
321
322        assert!(
323            !cache_path.exists(),
324            "logout on empty cache must not create the cache file"
325        );
326    }
327
328    #[test]
329    fn run_always_returns_ok() {
330        let dir = TempDir::new().unwrap();
331        let cache_path = dir.path().join("auth.toml");
332        let (args, cfg) = make_args("https://api.test.dasch.swiss");
333
334        let mut renderer = RecordingRenderer::new();
335        let result = run_impl(&args, &cfg, &mut renderer, Some(&cache_path));
336        assert!(
337            result.is_ok(),
338            "logout should always return Ok; got {result:?}"
339        );
340    }
341
342    #[test]
343    fn logout_meta_auth_is_anonymous() {
344        // Guards the ADR-0007 harmonization: after logout, _meta.auth must be
345        // "anonymous" (no token present), not the old "not_logged_in" string.
346        let dir = TempDir::new().unwrap();
347        let cache_path = dir.path().join("auth.toml");
348        let (args, cfg) = make_args("https://api.test.dasch.swiss");
349
350        let mut renderer = RecordingRenderer::new();
351        run_impl(&args, &cfg, &mut renderer, Some(&cache_path)).unwrap();
352
353        assert_eq!(
354            renderer.logout_auth_state.as_deref(),
355            Some("anonymous"),
356            "_meta.auth after logout must be 'anonymous' (ADR-0007 vocabulary)"
357        );
358    }
359}