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