max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
3 files changed,
+92 insertions,
-2 deletions
| @@ -629,6 +629,15 @@ | |||
| 629 | 629 | /// Headscale choice visible at the moment it is made, and the vendor's | |
| 630 | 630 | /// plane is the empty answer. | |
| 631 | 631 | server: Option<TextField>, | |
| 632 | + | /// Whether this machine can reach anything, as a seam. | |
| 633 | + | /// | |
| 634 | + | /// Injected rather than called directly so the enrollment tests state the | |
| 635 | + | /// network they are testing against. Reading `/proc/net/route` from inside | |
| 636 | + | /// a test makes the result depend on the machine running it: the same test | |
| 637 | + | /// would pass on a laptop and fail in a build container with no default | |
| 638 | + | /// route, and it would fail for a reason that has nothing to do with what | |
| 639 | + | /// it asserts. | |
| 640 | + | route: fn() -> bool, | |
| 632 | 641 | } | |
| 633 | 642 | ||
| 634 | 643 | impl MeshView { | |
| @@ -645,6 +654,7 @@ | |||
| 645 | 654 | error: None, | |
| 646 | 655 | ticks: 0, | |
| 647 | 656 | server: None, | |
| 657 | + | route: machine_has_route, | |
| 648 | 658 | }; | |
| 649 | 659 | view.refresh(log); | |
| 650 | 660 | view | |
| @@ -756,7 +766,7 @@ | |||
| 756 | 766 | return Flow::Continue; | |
| 757 | 767 | } | |
| 758 | 768 | }; | |
| 759 | - | if !machine_has_route() { | |
| 769 | + | if !(self.route)() { | |
| 760 | 770 | self.error = Some(NO_ROUTE.into()); | |
| 761 | 771 | self.close_enrollment(); | |
| 762 | 772 | return Flow::Continue; |
| @@ -179,6 +179,9 @@ | |||
| 179 | 179 | server: Option<TextField>, | |
| 180 | 180 | error: Option<String>, | |
| 181 | 181 | ticks: u64, | |
| 182 | + | /// Whether this machine can reach anything. See [`mesh::MeshView`]'s field | |
| 183 | + | /// of the same name for why it is a seam rather than a call. | |
| 184 | + | route: fn() -> bool, | |
| 182 | 185 | /// Refresh on the next tick regardless of the poll counter. | |
| 183 | 186 | /// | |
| 184 | 187 | /// Set before handing the terminal away. See the module docs: the shell's | |
| @@ -208,6 +211,7 @@ | |||
| 208 | 211 | server: None, | |
| 209 | 212 | error: None, | |
| 210 | 213 | ticks: 0, | |
| 214 | + | route: mesh::machine_has_route, | |
| 211 | 215 | stale: false, | |
| 212 | 216 | }; | |
| 213 | 217 | view.refresh(log); | |
| @@ -299,7 +303,7 @@ | |||
| 299 | 303 | return Flow::Continue; | |
| 300 | 304 | } | |
| 301 | 305 | }; | |
| 302 | - | if !mesh::machine_has_route() { | |
| 306 | + | if !(self.route)() { | |
| 303 | 307 | self.error = Some(mesh::NO_ROUTE.into()); | |
| 304 | 308 | self.server = None; | |
| 305 | 309 | return Flow::Continue; | |
| @@ -694,6 +698,7 @@ | |||
| 694 | 698 | fn enrolling_the_mesh_asks_for_a_server_then_suspends() { | |
| 695 | 699 | let mut log = CommandLog::new(); | |
| 696 | 700 | let mut view = view(false, true, &mut log); | |
| 701 | + | view.route = || true; | |
| 697 | 702 | assert_eq!(view.selected(), Some(Row::Mesh)); | |
| 698 | 703 | ||
| 699 | 704 | let flow = view.handle(press(KeyCode::Char('e')), &mut log); | |
| @@ -709,6 +714,39 @@ | |||
| 709 | 714 | assert!(view.server.is_none(), "the overlay closes on the way out"); | |
| 710 | 715 | } | |
| 711 | 716 | ||
| 717 | + | /// The same flow on a machine with no route stops before the handover. | |
| 718 | + | /// | |
| 719 | + | /// Measured on fw12 2026-09-07: a reinstall took the Wi-Fi with it, and | |
| 720 | + | /// enrolling escalated, took the password, and left the user on a blank | |
| 721 | + | /// terminal while `tailscale up` retried a control server it could not | |
| 722 | + | /// reach. The route is injected so this states its own network instead of | |
| 723 | + | /// inheriting whatever the machine running the suite happens to have. | |
| 724 | + | #[test] | |
| 725 | + | fn enrolling_the_mesh_without_a_route_refuses_and_names_the_screen() { | |
| 726 | + | let mut log = CommandLog::new(); | |
| 727 | + | let mut view = view(false, true, &mut log); | |
| 728 | + | view.route = || false; | |
| 729 | + | ||
| 730 | + | let flow = view.handle(press(KeyCode::Char('e')), &mut log); | |
| 731 | + | assert!(matches!(flow, Flow::Continue), "the overlay opens first"); | |
| 732 | + | ||
| 733 | + | let flow = view.handle(press(KeyCode::Enter), &mut log); | |
| 734 | + | assert!( | |
| 735 | + | matches!(flow, Flow::Continue), | |
| 736 | + | "a sign-in that cannot reach a control server must not take the terminal", | |
| 737 | + | ); | |
| 738 | + | let error = view.error.as_deref().expect("the refusal is reported"); | |
| 739 | + | assert!( | |
| 740 | + | error.contains("no network"), | |
| 741 | + | "it says what is wrong: {error}" | |
| 742 | + | ); | |
| 743 | + | assert!( | |
| 744 | + | error.contains("alloy net"), | |
| 745 | + | "it names the screen that fixes it: {error}", | |
| 746 | + | ); | |
| 747 | + | assert!(view.server.is_none(), "the overlay closes either way"); | |
| 748 | + | } | |
| 749 | + | ||
| 712 | 750 | // The reason this screen has a `stale` flag at all. The user comes back | |
| 713 | 751 | // from the mesh sign-in to take the other row, and the shell refreshes a | |
| 714 | 752 | // rebuilt view by calling `tick` once — which the poll counter would eat. |
| @@ -225,6 +225,7 @@ | |||
| 225 | 225 | error: None, | |
| 226 | 226 | ticks: 0, | |
| 227 | 227 | server: None, | |
| 228 | + | route: || true, | |
| 228 | 229 | }, | |
| 229 | 230 | CommandLog::new(), | |
| 230 | 231 | ) | |
| @@ -570,3 +571,44 @@ | |||
| 570 | 571 | )); | |
| 571 | 572 | assert!(!has_route("")); | |
| 572 | 573 | } | |
| 574 | + | ||
| 575 | + | /// With no route, enrolling says so and keeps the terminal. | |
| 576 | + | /// | |
| 577 | + | /// The seam is the point: this asserts the refusal on a machine that certainly | |
| 578 | + | /// has a route, which is every machine anyone runs the suite on. | |
| 579 | + | #[test] | |
| 580 | + | fn enrolling_without_a_route_refuses_and_names_the_screen() { | |
| 581 | + | let (mut view, mut log) = logged_out_view(); | |
| 582 | + | view.route = || false; | |
| 583 | + | view.open_enrollment(); | |
| 584 | + | ||
| 585 | + | let flow = view.submit_enrollment(&mut log); | |
| 586 | + | assert!( | |
| 587 | + | matches!(flow, Flow::Continue), | |
| 588 | + | "a sign-in that cannot reach a control server must not take the terminal", | |
| 589 | + | ); | |
| 590 | + | let error = view.error.as_deref().expect("the refusal is reported"); | |
| 591 | + | assert!( | |
| 592 | + | error.contains("no network"), | |
| 593 | + | "it says what is wrong: {error}" | |
| 594 | + | ); | |
| 595 | + | assert!( | |
| 596 | + | error.contains("alloy net"), | |
| 597 | + | "it names the screen that fixes it: {error}", | |
| 598 | + | ); | |
| 599 | + | assert!(view.server.is_none(), "the overlay closes either way"); | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | /// With a route, the same submission hands over as before. | |
| 603 | + | #[test] | |
| 604 | + | fn enrolling_with_a_route_still_suspends() { | |
| 605 | + | let (mut view, mut log) = logged_out_view(); | |
| 606 | + | view.route = || true; | |
| 607 | + | view.open_enrollment(); | |
| 608 | + | ||
| 609 | + | let flow = view.submit_enrollment(&mut log); | |
| 610 | + | assert!( | |
| 611 | + | matches!(flow, Flow::Suspend(_)), | |
| 612 | + | "signing in hands over the terminal", | |
| 613 | + | ); | |
| 614 | + | } |