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