package runtime import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "strconv" "sync" "testing" "time" "fun.chick/msa-agent/internal/egress" ) // NodeAuth is tested against a real local server because the property under test is the dialogue // with a real SimAdmin: status probe, login, cookie attach, 401 invalidation, and the cooldown // that keeps a wrong password from becoming a lockout. type authServer struct { mu sync.Mutex requests []string protected bool password string logins int } func (s *authServer) handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.mu.Lock() s.requests = append(s.requests, r.Method+" "+r.URL.Path) protected := s.protected password := s.password loggedIn := r.Header.Get("Cookie") == "simadmin_session=agent-session" if r.URL.Path == "/api/auth/login" { s.logins++ } s.mu.Unlock() switch { case r.URL.Path == "/api/auth/status": w.Header().Set("Content-Type", "application/json") if !protected { w.Write([]byte(`{"data":{"authenticated":true}}`)) } else if loggedIn { w.Write([]byte(`{"data":{"authenticated":true}}`)) } else { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"data":{"authenticated":false,"settings":{"password_protection_enabled":true}}}`)) } case r.URL.Path == "/api/auth/login": var body struct { Password string `json:"password"` } json.NewDecoder(r.Body).Decode(&body) if protected && body.Password == password { w.Header().Set("Set-Cookie", "simadmin_session=agent-session; Path=/; HttpOnly") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok":true}`)) } else { w.WriteHeader(http.StatusUnauthorized) } case r.URL.Path == "/api/health": if protected && !loggedIn { w.WriteHeader(http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok":true}`)) default: w.WriteHeader(http.StatusNotFound) } }) } func (s *authServer) count(path string) int { s.mu.Lock() defer s.mu.Unlock() n := 0 for _, r := range s.requests { if r == "GET "+path || r == "POST "+path { n++ } } return n } func (s *authServer) loginCount() int { s.mu.Lock() defer s.mu.Unlock() return s.logins } func startAuthServer(t *testing.T, protected bool, password string) (*authServer, int) { t.Helper() srv := &authServer{protected: protected, password: password} server := httptest.NewServer(srv.handler()) t.Cleanup(server.Close) parsed, err := url.Parse(server.URL) if err != nil { t.Fatal(err) } port, err := strconv.Atoi(parsed.Port()) if err != nil { t.Fatal(err) } return srv, port } func testAuth(t *testing.T, port int, password string, clock func() time.Time) (*NodeAuth, *http.Client) { t.Helper() target, err := egress.NewTarget(port) if err != nil { t.Fatal(err) } client, err := egress.NewClient(target, 2*time.Second) if err != nil { t.Fatal(err) } auth, err := NewNodeAuth(client, target, AuthConfig{Password: password, Clock: clock}) if err != nil { t.Fatal(err) } return auth, client } func TestUnprotectedNodeNeedsNoLogin(t *testing.T) { srv, port := startAuthServer(t, false, "") auth, _ := testAuth(t, port, "", nil) if err := auth.Ensure(context.Background()); err != nil { t.Fatalf("Ensure: %v", err) } if auth.Cookie() != "" { t.Fatalf("cookie %q on an open node", auth.Cookie()) } if srv.loginCount() != 0 { t.Fatal("an unprotected node was logged into") } } func TestProtectedNodeLogsInAndPresentsTheCookie(t *testing.T) { srv, port := startAuthServer(t, true, "hunter2") auth, client := testAuth(t, port, "hunter2", nil) if err := auth.Ensure(context.Background()); err != nil { t.Fatalf("Ensure: %v", err) } if auth.Cookie() == "" { t.Fatal("no cookie after a successful login") } request, _ := http.NewRequest(http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/api/health", nil) request.Header.Set("Cookie", auth.Cookie()) response, err := client.Do(request) if err != nil { t.Fatal(err) } defer response.Body.Close() if response.StatusCode != 200 { t.Fatalf("health with the agent cookie: %d", response.StatusCode) } if srv.count("/api/auth/status") != 1 { t.Fatalf("status probed %d times, want 1 (steady state is free)", srv.count("/api/auth/status")) } // Ensure again must not re-probe or re-login. if err := auth.Ensure(context.Background()); err != nil { t.Fatal(err) } if srv.count("/api/auth/status") != 1 { t.Fatal("a ready auth re-probed the status endpoint") } } func TestAProtectedNodeWithoutAPasswordFailsClosed(t *testing.T) { srv, port := startAuthServer(t, true, "hunter2") auth, _ := testAuth(t, port, "", nil) if err := auth.Ensure(context.Background()); err != ErrAuthNoPassword { t.Fatalf("got %v, want %v", err, ErrAuthNoPassword) } if srv.loginCount() != 0 { t.Fatal("a login was attempted without a configured password") } } // The cooldown is the property: a wrong password must not turn every command into a fresh login // attempt, because some devices lock accounts after repeated failures. func TestAWrongPasswordEntersTheCooldown(t *testing.T) { now := time.Unix(1_800_000_000, 0).UTC() clock := func() time.Time { return now } srv, port := startAuthServer(t, true, "hunter2") auth, _ := testAuth(t, port, "wrong", clock) if err := auth.Ensure(context.Background()); err == nil { t.Fatal("Ensure accepted a rejected login") } // Repeated Ensures inside the window must not re-attempt the login. for i := 0; i < 5; i++ { if err := auth.Ensure(context.Background()); err != ErrAuthRejected { t.Fatalf("attempt %d: got %v, want %v", i, err, ErrAuthRejected) } } if srv.loginCount() != 1 { t.Fatalf("login attempted %d times inside the cooldown, want 1", srv.loginCount()) } // Past the window it tries once more, so a fixed password recovers without a restart. now = now.Add(DefaultLoginCooldown + time.Second) if err := auth.Ensure(context.Background()); err == nil { t.Fatal("Ensure accepted a rejected login after the cooldown") } if srv.loginCount() != 2 { t.Fatalf("login attempted %d times after the window, want 2", srv.loginCount()) } } // A SimAdmin restart drops its sessions. Without invalidation every later command 401s until the // agent restarts too, which is indistinguishable to the user from a broken pairing. func TestA401FromTheNodeInvalidatesTheSession(t *testing.T) { srv, port := startAuthServer(t, true, "hunter2") auth, _ := testAuth(t, port, "hunter2", nil) if err := auth.Ensure(context.Background()); err != nil { t.Fatal(err) } // Simulate the node losing its session store: logins still work, old cookies do not. srv.mu.Lock() srv.password = "hunter2" // unchanged; the cookie check is what fails now srv.mu.Unlock() auth.Invalidate() if err := auth.Ensure(context.Background()); err != nil { t.Fatalf("Ensure after invalidation: %v", err) } if auth.Cookie() == "" { t.Fatal("the session was not re-established") } if srv.loginCount() != 2 { t.Fatalf("logins: %d, want 2", srv.loginCount()) } } // Executor-level: a command to a protected node succeeds end to end, and the phone never sees the // login traffic — it is not on the allowlist and policy rejects it before any dial. func TestExecutorLogsInBeforeTheFirstCommand(t *testing.T) { srv, port := startAuthServer(t, true, "hunter2") pol := testPolicy(t) executor, err := NewExecutor(pol, Config{ Port: port, Timeout: 2 * time.Second, Audit: NullAudit(), NodePassword: "hunter2", }) if err != nil { t.Fatal(err) } result, refusal := executor.Execute(context.Background(), envelopeAt("nd_test0001", "f1f1f1f1-f1f1-41f1-81f1-f1f1f1f1f1f1", uint64(time.Now().Unix())), request("GET", "/api/health", nil)) if refusal != nil { t.Fatalf("refused: %v", refusal) } if result.Status != 200 { t.Fatalf("status %d", result.Status) } if srv.loginCount() != 1 { t.Fatalf("logins: %d", srv.loginCount()) } }