Skip to main content

max / makenotwork

26.0 KB · 819 lines History Blame Raw
1 //! What magicmirror is pointed at.
2 //!
3 //! Adding a service is a config edit and nothing else — no code change, no new
4 //! tab type, no widget. That property is what makes magicmirror modular rather
5 //! than merely tabbed, and it is worth defending: the moment a source needs a
6 //! special case here, the shell has started learning domain vocabulary.
7
8 use std::path::{Path, PathBuf};
9 use std::time::Duration;
10
11 use anyhow::{Context, Result};
12 use serde::Deserialize;
13
14 /// Default poll interval. Matches what the Sando TUI already did.
15 const DEFAULT_POLL_SECS: u64 = 5;
16
17 /// Default age past which a source's answer stops counting as current.
18 ///
19 /// Deliberately a small multiple of the poll interval rather than something
20 /// generous: the whole point is that a source which quietly stopped updating
21 /// looks different from one that is fine.
22 const DEFAULT_STALE_SECS: u64 = 60;
23
24 /// Default interval between store reads.
25 ///
26 /// Far slower than a source poll, because a store is written by a batch
27 /// producer: witchbroom's sweep runs for hours and writes once. Re-reading it
28 /// every five seconds would be spending a query to learn nothing.
29 const DEFAULT_STORE_POLL_SECS: u64 = 60;
30
31 #[derive(Debug, Clone, Deserialize)]
32 pub(crate) struct Config {
33 /// Fallback for any source that does not set its own.
34 #[serde(default = "default_stale")]
35 pub stale_after_secs: u64,
36 /// The theme to render in: `"system"` to follow the terminal, or a theme
37 /// id to pin.
38 ///
39 /// Unprefixed `theme`, which is the family convention's key name (wiki
40 /// `makeover-app-convention`) — this file is already scoped to magicmirror,
41 /// so a `magicmirror-theme` would be saying it twice. Absent reads as "follow
42 /// the terminal" rather than as a pin on whatever the first run guessed.
43 #[serde(default)]
44 pub theme: Option<String>,
45 #[serde(default, rename = "source")]
46 pub sources: Vec<Source>,
47 /// Observation stores to render on the store tab.
48 ///
49 /// Optional, and normally absent: a store is a producer's own database,
50 /// read directly, which is the one thing here that is not the `ops-status`
51 /// contract. Nothing is shown unless it is named below.
52 #[serde(default, rename = "store")]
53 pub stores: Vec<Store>,
54 }
55
56 /// One observation store, and which of its series are worth rendering.
57 ///
58 /// **Meaning lives here, not in the data.** witchbroom's store is deliberately
59 /// `(series, labels, value, at)` with an `f64`, so the schema cannot say what a
60 /// series is or what unit it is in. Inferring it would mean guessing, and
61 /// rendering every series unlabelled would make this a table browser, which is
62 /// what the ruling rejected. So a series is named here with what it means, and
63 /// a series nobody named is not shown. Silence over noise, on purpose.
64 #[derive(Debug, Clone, Deserialize)]
65 pub(crate) struct Store {
66 /// What to call this store on screen.
67 pub name: String,
68 /// The SQLite file. A leading `~` expands at load, same as a file source.
69 pub path: PathBuf,
70 #[serde(default = "default_store_poll")]
71 pub poll_secs: u64,
72 #[serde(default, rename = "series")]
73 pub series: Vec<Series>,
74 }
75
76 /// One series worth rendering, and what it means.
77 #[derive(Debug, Clone, Deserialize)]
78 pub(crate) struct Series {
79 /// The `series` value in the store, verbatim. Spelled `series` in the file,
80 /// where `[[store.series]]` has already said what kind of thing it is.
81 #[serde(rename = "series")]
82 pub name: String,
83 /// What to call it on screen.
84 pub label: String,
85 /// What the number is in. Optional because some numbers are counts of
86 /// nothing in particular.
87 #[serde(default)]
88 pub unit: Option<String>,
89 }
90
91 /// Where one source's payload comes from.
92 ///
93 /// Two shapes, because two shapes of producer exist. A daemon is polled over
94 /// HTTP. A batch producer runs, writes, and stops, so it has nothing to poll:
95 /// witchbroom's sweep takes hours and then the machine is idle until tomorrow.
96 /// Making it grow an HTTP listener to be visible would be paying a daemon's
97 /// cost for a producer that is not one, and the panel already keeps the last
98 /// payload across a failed poll, which is exactly the property a batch producer
99 /// needs. So a source can be a file, read on the same tick.
100 #[derive(Debug, Clone, PartialEq, Eq)]
101 pub(crate) enum Origin {
102 /// The base URL with `/status.json` appended.
103 Http(String),
104 File(PathBuf),
105 }
106
107 #[derive(Debug, Clone, Deserialize)]
108 pub(crate) struct Source {
109 /// Tab label, and the `source` name the payload should carry.
110 pub name: String,
111 /// Base URL of a daemon to poll. `/status.json` is appended. Exactly one of
112 /// this and `path` is set; `validate` refuses anything else.
113 #[serde(default)]
114 pub url: Option<String>,
115 /// A payload on disk, rewritten by a batch producer at the end of its run.
116 ///
117 /// A leading `~` expands at load. Nothing else about it is interpreted: the
118 /// full filename is given, not a base to append `status.json` to, because a
119 /// producer writing one file has no reason to own a directory.
120 #[serde(default)]
121 pub path: Option<PathBuf>,
122 /// Name of the environment variable holding this source's bearer token.
123 ///
124 /// The token is named, never inlined: this file describes topology and has
125 /// every reason to be readable, while the daemons already take their tokens
126 /// from the environment (`SANDO_API_TOKEN`, `BENTO_API_TOKEN`). A config
127 /// format that invites pasting a prod token into a file is one that ends
128 /// with a prod token in a file.
129 #[serde(default)]
130 pub token_env: Option<String>,
131 #[serde(default = "default_poll")]
132 pub poll_secs: u64,
133 #[serde(default)]
134 pub stale_after_secs: Option<u64>,
135 /// Whether this source's declared actions may be fired from magicmirror.
136 ///
137 /// Defaults off. magicmirror is a display first, and some declared actions
138 /// (sando's `promote-b`, `rollback-b`) move production. Firing is opt-in per
139 /// source so that pointing magicmirror at a daemon can never move it by
140 /// accident: the operator turns a source's actions on deliberately, the same
141 /// place they already name its token. A source with this off still renders
142 /// its actions; it just refuses to issue them.
143 #[serde(default)]
144 pub allow_actions: bool,
145 }
146
147 /// `~` in a configured path, against `$HOME`.
148 ///
149 /// A file source's path is written by hand and the obvious one to write is
150 /// `~/.local/state/witchbroom/status.json`. Without this it resolves to a
151 /// literal `./~` and the tab reads as a producer that never ran.
152 fn expand_tilde(p: &Path) -> PathBuf {
153 let Ok(rest) = p.strip_prefix("~") else {
154 return p.to_path_buf();
155 };
156 match std::env::var_os("HOME") {
157 Some(home) => PathBuf::from(home).join(rest),
158 None => p.to_path_buf(),
159 }
160 }
161
162 fn default_poll() -> u64 {
163 DEFAULT_POLL_SECS
164 }
165
166 fn default_stale() -> u64 {
167 DEFAULT_STALE_SECS
168 }
169
170 fn default_store_poll() -> u64 {
171 DEFAULT_STORE_POLL_SECS
172 }
173
174 impl Source {
175 /// Where this source's payload comes from.
176 ///
177 /// Total, because `validate` has already refused every config that would
178 /// make it partial. A source with neither is rejected at load rather than
179 /// becoming a tab that can never say anything.
180 pub(crate) fn origin(&self) -> Origin {
181 match (&self.url, &self.path) {
182 (Some(url), _) => Origin::Http(format!("{}/status.json", url.trim_end_matches('/'))),
183 (None, Some(path)) => Origin::File(path.clone()),
184 (None, None) => unreachable!("validate rejects a source with neither url nor path"),
185 }
186 }
187
188 pub(crate) fn poll_interval(&self) -> Duration {
189 Duration::from_secs(self.poll_secs.max(1))
190 }
191
192 /// Resolve a declared action's `url` against this source's base.
193 ///
194 /// An action carries a path (`/rollback/b`); a producer that instead gives a
195 /// full URL is honored as-is, so a daemon can point an action at somewhere
196 /// other than itself without magicmirror second-guessing it.
197 ///
198 /// A file source is refused `allow_actions` at load and the UI refuses to
199 /// fire without it, so the no-base arm is unreachable rather than a policy:
200 /// a path is not something an action's URL can be resolved against.
201 pub(crate) fn action_url(&self, path: &str) -> String {
202 if path.starts_with("http://") || path.starts_with("https://") {
203 return path.to_string();
204 }
205 let Some(base) = self.url.as_deref() else {
206 return path.to_string();
207 };
208 format!(
209 "{}/{}",
210 base.trim_end_matches('/'),
211 path.trim_start_matches('/')
212 )
213 }
214
215 /// Resolve the bearer token from the environment, if one is named.
216 pub(crate) fn token(&self) -> Option<String> {
217 self.token_env
218 .as_deref()
219 .and_then(|name| std::env::var(name).ok())
220 .filter(|t| !t.is_empty())
221 }
222 }
223
224 impl Config {
225 pub(crate) fn load(path: &Path) -> Result<Self> {
226 let raw = std::fs::read_to_string(path)
227 .with_context(|| format!("reading magicmirror config at {}", path.display()))?;
228 let mut cfg: Config = toml::from_str(&raw)
229 .with_context(|| format!("parsing magicmirror config at {}", path.display()))?;
230 for store in &mut cfg.stores {
231 store.path = expand_tilde(&store.path);
232 }
233 for source in &mut cfg.sources {
234 if let Some(p) = &source.path {
235 source.path = Some(expand_tilde(p));
236 }
237 }
238 cfg.validate()?;
239 Ok(cfg)
240 }
241
242 fn validate(&self) -> Result<()> {
243 anyhow::ensure!(
244 !self.sources.is_empty(),
245 "no [[source]] entries: magicmirror would have nothing to show"
246 );
247 let mut seen = std::collections::HashSet::new();
248 for source in &self.sources {
249 anyhow::ensure!(
250 seen.insert(source.name.as_str()),
251 "duplicate source name {:?}: tabs would be ambiguous",
252 source.name
253 );
254 match (&source.url, &source.path) {
255 (Some(url), None) => anyhow::ensure!(
256 url.starts_with("http://") || url.starts_with("https://"),
257 "source {:?} url must start with http:// or https://",
258 source.name
259 ),
260 (None, Some(_)) => {
261 // Both are things a URL source has and a file has no
262 // equivalent of, and both are quiet when ignored. A token
263 // silently unused reads as a source that is authenticated;
264 // `allow_actions = true` silently unused reads as a source
265 // that can be driven. Refusing says which it is.
266 anyhow::ensure!(
267 source.token_env.is_none(),
268 "source {:?} is a file and cannot present a bearer token",
269 source.name
270 );
271 anyhow::ensure!(
272 !source.allow_actions,
273 "source {:?} is a file: an action is a URL and there is no base to \
274 resolve one against",
275 source.name
276 );
277 }
278 (Some(_), Some(_)) => anyhow::bail!(
279 "source {:?} sets both url and path: one payload, one origin",
280 source.name
281 ),
282 (None, None) => anyhow::bail!(
283 "source {:?} sets neither url nor path: it could never say anything",
284 source.name
285 ),
286 }
287 }
288
289 let mut seen_stores = std::collections::HashSet::new();
290 for store in &self.stores {
291 anyhow::ensure!(
292 seen_stores.insert(store.name.as_str()),
293 "duplicate store name {:?}: its rows would be ambiguous",
294 store.name
295 );
296 // A store with no series is not a store showing nothing, it is a
297 // config that can never say anything -- the same refusal a source
298 // with neither url nor path gets, for the same reason.
299 anyhow::ensure!(
300 !store.series.is_empty(),
301 "store {:?} declares no [[store.series]]: nothing would be rendered, \
302 and an unnamed series is not shown",
303 store.name
304 );
305 let mut seen_series = std::collections::HashSet::new();
306 for series in &store.series {
307 anyhow::ensure!(
308 seen_series.insert(series.name.as_str()),
309 "store {:?} names series {:?} twice",
310 store.name,
311 series.name
312 );
313 }
314 }
315 Ok(())
316 }
317
318 /// Interval between reads of one store.
319 pub(crate) fn store_interval(store: &Store) -> Duration {
320 Duration::from_secs(store.poll_secs.max(1))
321 }
322
323 /// What the operator chose, which is not the same as what is rendered: a
324 /// standing "follow the terminal" resolves differently as the terminal
325 /// changes, and a pinned id does not.
326 pub(crate) fn theme_selection(&self) -> makeover::ThemeSelection {
327 makeover::ThemeSelection::parse(self.theme.as_deref())
328 }
329
330 /// Staleness limit for one source: its own, else the global default.
331 pub(crate) fn stale_after(&self, source: &Source) -> chrono::TimeDelta {
332 let secs = source.stale_after_secs.unwrap_or(self.stale_after_secs);
333 chrono::TimeDelta::seconds(secs as i64)
334 }
335 }
336
337 #[cfg(test)]
338 mod tests {
339 use super::*;
340
341 fn write(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
342 let dir = tempfile::tempdir().unwrap();
343 let path = dir.path().join("magicmirror.toml");
344 std::fs::write(&path, body).unwrap();
345 (dir, path)
346 }
347
348 #[test]
349 fn a_minimal_source_gets_sensible_defaults() {
350 let (_dir, path) = write(
351 r#"
352 [[source]]
353 name = "sando"
354 url = "http://fw13:8080"
355 "#,
356 );
357 let cfg = Config::load(&path).unwrap();
358 assert_eq!(
359 cfg.sources[0].origin(),
360 Origin::Http("http://fw13:8080/status.json".into())
361 );
362 assert_eq!(cfg.sources[0].poll_interval().as_secs(), DEFAULT_POLL_SECS);
363 assert_eq!(
364 cfg.stale_after(&cfg.sources[0]),
365 chrono::TimeDelta::seconds(DEFAULT_STALE_SECS as i64)
366 );
367 }
368
369 #[test]
370 fn an_action_path_resolves_against_the_base_and_a_full_url_is_left_alone() {
371 let source: Source = toml::from_str(
372 r#"
373 name = "sando"
374 url = "http://fw13:8080/"
375 "#,
376 )
377 .unwrap();
378 assert_eq!(
379 source.action_url("/rollback/b"),
380 "http://fw13:8080/rollback/b"
381 );
382 assert_eq!(
383 source.action_url("rollback/b"),
384 "http://fw13:8080/rollback/b"
385 );
386 assert_eq!(
387 source.action_url("https://elsewhere/x"),
388 "https://elsewhere/x"
389 );
390 }
391
392 #[test]
393 fn a_trailing_slash_does_not_double_up() {
394 let (_dir, path) = write(
395 r#"
396 [[source]]
397 name = "bento"
398 url = "http://fw13:8090/"
399 "#,
400 );
401 let cfg = Config::load(&path).unwrap();
402 assert_eq!(
403 cfg.sources[0].origin(),
404 Origin::Http("http://fw13:8090/status.json".into())
405 );
406 }
407
408 #[test]
409 fn the_shipped_example_config_parses() {
410 // The example is the documentation, and it is the file people copy. A
411 // key renamed in code and not there is a config that fails on first
412 // run, which is the worst place to find out.
413 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
414 .join("deploy/magicmirror.toml.example");
415 let cfg = Config::load(&path).unwrap_or_else(|e| panic!("{e:?}"));
416 assert!(!cfg.sources.is_empty());
417 }
418
419 #[test]
420 fn the_store_block_in_the_shipped_example_parses_when_uncommented() {
421 // The store half of the example is commented out because a store is
422 // optional, which puts it outside the test above. Uncomment it here so
423 // it cannot drift either.
424 let raw = std::fs::read_to_string(
425 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
426 .join("deploy/magicmirror.toml.example"),
427 )
428 .unwrap();
429 let uncommented: String = raw
430 .lines()
431 .skip_while(|l| !l.starts_with("# [[store]]"))
432 .map(|l| {
433 l.strip_prefix("# ")
434 .or_else(|| l.strip_prefix('#'))
435 .unwrap_or(l)
436 })
437 .collect::<Vec<_>>()
438 .join("\n");
439 let cfg: Config = toml::from_str(&uncommented).unwrap_or_else(|e| panic!("{e:?}"));
440 let store = &cfg.stores[0];
441 assert_eq!(store.name, "witchbroom");
442 assert_eq!(store.series.len(), 2);
443 assert_eq!(store.series[0].name, "soak.coverage_edges");
444 assert_eq!(store.series[0].label, "Coverage reached");
445 }
446
447 #[test]
448 fn a_store_declares_its_series_and_expands_a_tilde_path() {
449 let (_dir, path) = write(
450 r#"
451 [[source]]
452 name = "sando"
453 url = "http://fw13:8080"
454
455 [[store]]
456 name = "witchbroom"
457 path = "~/.local/state/witchbroom/observations.db"
458
459 [[store.series]]
460 series = "soak.coverage_edges"
461 label = "Coverage reached"
462 unit = "edges"
463
464 [[store.series]]
465 series = "cache.size_bytes"
466 label = "Cache size"
467 "#,
468 );
469 let cfg = Config::load(&path).unwrap();
470 let store = &cfg.stores[0];
471 assert!(
472 !store.path.starts_with("~"),
473 "a store path expands like a source path: {}",
474 store.path.display()
475 );
476 assert_eq!(store.series.len(), 2);
477 assert_eq!(store.series[0].unit.as_deref(), Some("edges"));
478 // The unit is genuinely optional: some numbers count nothing in
479 // particular.
480 assert_eq!(store.series[1].unit, None);
481 assert_eq!(
482 Config::store_interval(store).as_secs(),
483 DEFAULT_STORE_POLL_SECS
484 );
485 }
486
487 #[test]
488 fn a_config_with_no_store_block_is_fine() {
489 let (_dir, path) = write(
490 r#"
491 [[source]]
492 name = "sando"
493 url = "http://fw13:8080"
494 "#,
495 );
496 assert!(Config::load(&path).unwrap().stores.is_empty());
497 }
498
499 #[test]
500 fn a_store_with_no_series_is_refused() {
501 // Not a store showing nothing: a config that can never say anything,
502 // because an unnamed series is not rendered.
503 let (_dir, path) = write(
504 r#"
505 [[source]]
506 name = "sando"
507 url = "http://fw13:8080"
508
509 [[store]]
510 name = "witchbroom"
511 path = "/tmp/x.db"
512 "#,
513 );
514 let err = Config::load(&path).unwrap_err().to_string();
515 assert!(err.contains("store.series"), "{err}");
516 }
517
518 #[test]
519 fn a_duplicate_store_or_series_name_is_refused() {
520 let (_dir, path) = write(
521 r#"
522 [[source]]
523 name = "sando"
524 url = "http://fw13:8080"
525
526 [[store]]
527 name = "witchbroom"
528 path = "/tmp/x.db"
529 [[store.series]]
530 series = "a"
531 label = "A"
532 [[store.series]]
533 series = "a"
534 label = "Also A"
535 "#,
536 );
537 let err = Config::load(&path).unwrap_err().to_string();
538 assert!(err.contains("twice"), "{err}");
539
540 let (_dir, path) = write(
541 r#"
542 [[source]]
543 name = "sando"
544 url = "http://fw13:8080"
545
546 [[store]]
547 name = "witchbroom"
548 path = "/tmp/x.db"
549 [[store.series]]
550 series = "a"
551 label = "A"
552
553 [[store]]
554 name = "witchbroom"
555 path = "/tmp/y.db"
556 [[store.series]]
557 series = "b"
558 label = "B"
559 "#,
560 );
561 let err = Config::load(&path).unwrap_err().to_string();
562 assert!(err.contains("duplicate store"), "{err}");
563 }
564
565 /// The batch-producer shape. witchbroom runs for hours and stops, so it has
566 /// nothing to poll; the file it leaves behind is the source.
567 #[test]
568 fn a_source_can_be_a_file_on_disk() {
569 let (_dir, path) = write(
570 r#"
571 [[source]]
572 name = "witchbroom"
573 path = "/var/lib/witchbroom/status.json"
574 poll_secs = 60
575 stale_after_secs = 129600
576 "#,
577 );
578 let cfg = Config::load(&path).unwrap();
579 assert_eq!(
580 cfg.sources[0].origin(),
581 Origin::File("/var/lib/witchbroom/status.json".into())
582 );
583 // A nightly producer is not stale at 60 seconds, and the per-source
584 // override is what makes one legible next to a daemon polled at 5.
585 assert_eq!(cfg.stale_after(&cfg.sources[0]).num_seconds(), 129_600);
586 }
587
588 /// The path an operator actually writes. Left literal it resolves to `./~`
589 /// and the tab reads as a producer that never ran.
590 #[test]
591 fn a_leading_tilde_in_a_path_expands() {
592 let home = std::env::var("HOME").unwrap();
593 let (_dir, path) = write(
594 r#"
595 [[source]]
596 name = "witchbroom"
597 path = "~/.local/state/witchbroom/status.json"
598 "#,
599 );
600 let cfg = Config::load(&path).unwrap();
601 assert_eq!(
602 cfg.sources[0].origin(),
603 Origin::File(
604 std::path::PathBuf::from(home).join(".local/state/witchbroom/status.json")
605 )
606 );
607 }
608
609 /// One payload, one origin. Both set is a config whose meaning has to be
610 /// guessed, and either guess makes half of it dead text.
611 #[test]
612 fn a_source_sets_exactly_one_of_url_and_path() {
613 let (_dir, path) = write(
614 r#"
615 [[source]]
616 name = "witchbroom"
617 url = "http://astra:9000"
618 path = "/var/lib/witchbroom/status.json"
619 "#,
620 );
621 assert!(
622 Config::load(&path)
623 .unwrap_err()
624 .to_string()
625 .contains("one payload, one origin")
626 );
627
628 let (_dir, path) = write(
629 r#"
630 [[source]]
631 name = "witchbroom"
632 "#,
633 );
634 assert!(
635 Config::load(&path)
636 .unwrap_err()
637 .to_string()
638 .contains("neither url nor path")
639 );
640 }
641
642 /// Both of these are quiet when ignored, and both read as a promise the
643 /// file source cannot keep: authenticated, or drivable.
644 #[test]
645 fn a_file_source_refuses_a_token_and_refuses_actions() {
646 let (_dir, path) = write(
647 r#"
648 [[source]]
649 name = "witchbroom"
650 path = "/var/lib/witchbroom/status.json"
651 token_env = "WITCHBROOM_TOKEN"
652 "#,
653 );
654 assert!(
655 Config::load(&path)
656 .unwrap_err()
657 .to_string()
658 .contains("bearer token")
659 );
660
661 let (_dir, path) = write(
662 r#"
663 [[source]]
664 name = "witchbroom"
665 path = "/var/lib/witchbroom/status.json"
666 allow_actions = true
667 "#,
668 );
669 assert!(
670 Config::load(&path)
671 .unwrap_err()
672 .to_string()
673 .contains("no base to resolve")
674 );
675 }
676
677 #[test]
678 fn a_per_source_staleness_overrides_the_default() {
679 let (_dir, path) = write(
680 r#"
681 stale_after_secs = 60
682
683 [[source]]
684 name = "sando"
685 url = "http://fw13:8080"
686
687 [[source]]
688 name = "pom"
689 url = "http://pom:9000"
690 stale_after_secs = 600
691 "#,
692 );
693 let cfg = Config::load(&path).unwrap();
694 assert_eq!(cfg.stale_after(&cfg.sources[0]).num_seconds(), 60);
695 assert_eq!(cfg.stale_after(&cfg.sources[1]).num_seconds(), 600);
696 }
697
698 #[test]
699 fn an_absent_theme_key_follows_the_terminal_and_an_id_pins_one() {
700 let (_dir, path) = write(
701 r#"
702 [[source]]
703 name = "sando"
704 url = "http://fw13:8080"
705 "#,
706 );
707 let cfg = Config::load(&path).unwrap();
708 assert_eq!(
709 cfg.theme_selection(),
710 makeover::ThemeSelection::Follow,
711 "a config that says nothing about theming must track the terminal, \
712 not pin whatever the first run guessed",
713 );
714
715 let (_dir, path) = write(
716 r#"
717 theme = "carbonfox"
718
719 [[source]]
720 name = "sando"
721 url = "http://fw13:8080"
722 "#,
723 );
724 assert_eq!(
725 Config::load(&path).unwrap().theme_selection(),
726 makeover::ThemeSelection::Fixed("carbonfox".into()),
727 );
728 }
729
730 #[test]
731 fn an_empty_config_is_rejected_rather_than_showing_an_empty_screen() {
732 let (_dir, path) = write("stale_after_secs = 60\n");
733 assert!(
734 Config::load(&path)
735 .unwrap_err()
736 .to_string()
737 .contains("no [[source]]")
738 );
739 }
740
741 #[test]
742 fn duplicate_source_names_are_rejected() {
743 let (_dir, path) = write(
744 r#"
745 [[source]]
746 name = "sando"
747 url = "http://a"
748 [[source]]
749 name = "sando"
750 url = "http://b"
751 "#,
752 );
753 assert!(
754 Config::load(&path)
755 .unwrap_err()
756 .to_string()
757 .contains("duplicate")
758 );
759 }
760
761 #[test]
762 fn a_url_without_a_scheme_is_rejected() {
763 let (_dir, path) = write(
764 r#"
765 [[source]]
766 name = "sando"
767 url = "fw13:8080"
768 "#,
769 );
770 assert!(
771 Config::load(&path)
772 .unwrap_err()
773 .to_string()
774 .contains("http://")
775 );
776 }
777
778 #[test]
779 fn actions_are_off_unless_a_source_opts_in() {
780 let (_dir, path) = write(
781 r#"
782 [[source]]
783 name = "pom"
784 url = "http://pom:9000"
785
786 [[source]]
787 name = "sando"
788 url = "http://fw13:8080"
789 allow_actions = true
790 "#,
791 );
792 let cfg = Config::load(&path).unwrap();
793 assert!(
794 !cfg.sources[0].allow_actions,
795 "a source must not be able to move production by default"
796 );
797 assert!(cfg.sources[1].allow_actions);
798 }
799
800 #[test]
801 fn a_token_is_read_from_the_environment_never_the_file() {
802 let (_dir, path) = write(
803 r#"
804 [[source]]
805 name = "sando"
806 url = "http://fw13:8080"
807 token_env = "OPS_VIEWER_TEST_TOKEN"
808 "#,
809 );
810 let cfg = Config::load(&path).unwrap();
811 // Unset -> no token rather than an empty string masquerading as one.
812 unsafe { std::env::remove_var("OPS_VIEWER_TEST_TOKEN") };
813 assert_eq!(cfg.sources[0].token(), None);
814 unsafe { std::env::set_var("OPS_VIEWER_TEST_TOKEN", "s3cr3t") };
815 assert_eq!(cfg.sources[0].token().as_deref(), Some("s3cr3t"));
816 unsafe { std::env::remove_var("OPS_VIEWER_TEST_TOKEN") };
817 }
818 }
819