- runtime.NodeAuth:agent 自查 /api/auth/status,需要时自行 POST /api/auth/login 并持 simadmin_session Cookie 转发;错误密码进入 60s 冷却(对齐控制台 ensureAuthenticated), 节点回 401/403 即失效重探,SimAdmin 重启无需重启 agent - 节点密码进 agent 配置(nodePassword),永不经中继、永不下发到手机;登录路径仍被策略 挡在电话可达面之外(TestAuthPathsAreUnreachable) - 新拒绝分类 node_auth_failed:与 upstream_error 区分,手机可提示'该节点需重新配置' - 中继:/v1/request 每设备固定窗口限流(默认 30/分钟),未鉴权调用不消耗预算 - transport:解析 Retry-After 并入退避;main loop 对 429 直接遵从中继指示 - contracts/envelope.schema.json 补 InnerResponse.outcome 字段 - 设计文档与 README 同步:WSS 改为长轮询的实现修订、响应方向已临时×临时、P2/P3 状态
261 lines
7.8 KiB
Go
261 lines
7.8 KiB
Go
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())
|
|
}
|
|
}
|