package host import ( "fmt" "net/http" "net/http/httptest" "testing" "time" ) // The dial/serve/handshake tests for this channel live in // internal/tunnelclient, where that code now lives. What is testable HERE is // the agent's wrapper: the env convention, and the state transitions the // status heartbeat reads off tunnelState(). // TestTunnelLoopStateTransitions drives tunnelLoop against a fake edge that // hijacks, answers 101, then holds the raw conn open until the test releases // it — enough to observe tunnelState() go "" -> "connected", and then // "connected" -> "reconnecting" once the conn is torn down. This is the state // Task 6's heartbeat probe reads, so its transitions are worth asserting // through the wrapper that publishes them, not only in the package that // reports them. func TestTunnelLoopStateTransitions(t *testing.T) { release := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, bufrw, err := w.(http.Hijacker).Hijack() if err != nil { t.Errorf("hijack: %v", err) return } fmt.Fprintf(bufrw, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: carlos-tunnel/1\r\nConnection: Upgrade\r\n\r\n") bufrw.Flush() <-release conn.Close() })) defer srv.Close() t.Setenv("CARLOS_TUNNEL_URL", srv.URL) t.Setenv("CARLOS_TUNNEL_TOKEN", "sekrit") t.Setenv("CARLOS_TUNNEL_NAME", "pi-box") // tunnelLoop never returns while configured (by design — it retries // forever on context.Background()), so this goroutine outlives the test; // the fake edge is torn down via defer, and any further redial attempts // just fail fast against a closed listener. go tunnelLoop(http.NewServeMux()) waitForTunnelState(t, "connected") close(release) waitForTunnelState(t, "reconnecting") } // TestTunnelLoopInertUnlessFullyConfigured checks the documented convention: // tunnelLoop must return immediately — never dial, never block — unless all // three of CARLOS_TUNNEL_URL/_TOKEN/_NAME are set. func TestTunnelLoopInertUnlessFullyConfigured(t *testing.T) { cases := []struct { name, url, token, tname string }{ {"all unset", "", "", ""}, {"url only", "http://tunnel.invalid", "", ""}, {"token only", "", "tok", ""}, {"name only", "", "", "pi-box"}, {"url and token", "http://tunnel.invalid", "tok", ""}, {"url and name", "http://tunnel.invalid", "", "pi-box"}, {"token and name", "", "tok", "pi-box"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Setenv("CARLOS_TUNNEL_URL", c.url) t.Setenv("CARLOS_TUNNEL_TOKEN", c.token) t.Setenv("CARLOS_TUNNEL_NAME", c.tname) done := make(chan struct{}) go func() { tunnelLoop(http.NewServeMux()) close(done) }() select { case <-done: case <-time.After(500 * time.Millisecond): t.Fatal("tunnelLoop did not return promptly with an incomplete env config") } }) } } func waitForTunnelState(t *testing.T, want string) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if tunnelState() == want { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("tunnelState() = %q, want %q before deadline", tunnelState(), want) }