节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
9.64 KiB
Stardust
package fakeserver

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"net/http"
	"testing"
	"time"

	"github.com/coder/websocket"

	"staragent-conformance/internal/protocol"
)

func startServer(t *testing.T, opts Options) *Server {
	t.Helper()
	srv := New(opts)
	if err := srv.Start(); err != nil {
		t.Fatalf("Start: %v", err)
	}
	t.Cleanup(func() { _ = srv.Close() })

	return srv
}

func doJSON(t *testing.T, method, url string, body any, token string) (int, []byte) {
	t.Helper()
	var reader io.Reader
	if body != nil {
		data, err := json.Marshal(body)
		if err != nil {
			t.Fatalf("marshal: %v", err)
		}
		reader = bytes.NewReader(data)
	}
	req, err := http.NewRequest(method, url, reader)
	if err != nil {
		t.Fatalf("request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("%s %s: %v", method, url, err)
	}
	defer resp.Body.Close()
	data, _ := io.ReadAll(resp.Body)

	return resp.StatusCode, data
}

func login(t *testing.T, srv *Server, code, secret string) (int, protocol.LoginResponse, []byte) {
	t.Helper()
	body := protocol.LoginRequest{
		Code:        code,
		Secret:      secret,
		ProductCode: "StarAgent-Test",
		Node: &protocol.NodeInfo{
			MachineName: "test-host",
			Compile:     protocol.FormatDateTime(time.Now()),
			Plugins:     []string{"agent:test"},
		},
	}
	status, data := doJSON(t, http.MethodPost, srv.URL()+"/node/login", body, "")
	var resp protocol.LoginResponse
	if status == http.StatusOK {
		if err := json.Unmarshal(data, &resp); err != nil {
			t.Fatalf("decode login: %v (%s)", err, data)
		}
	}

	return status, resp, data
}

func TestLoginPingUpgradeLogout(t *testing.T) {
	srv := startServer(t, Options{})

	status, first, _ := login(t, srv, "", "")
	if status != http.StatusOK {
		t.Fatalf("first login status = %d", status)
	}
	if first.Code == "" || first.Secret == "" || first.Token == "" {
		t.Fatalf("first login response = %+v", first)
	}
	if first.ServerTime <= 0 || first.Expire <= 0 {
		t.Fatalf("first login ServerTime/Expire = %d/%d", first.ServerTime, first.Expire)
	}

	status, _, _ = login(t, srv, first.Code, "wrong")
	if status != http.StatusUnauthorized {
		t.Fatalf("login with wrong secret status = %d, want 401", status)
	}

	status, second, _ := login(t, srv, first.Code, first.Secret)
	if status != http.StatusOK {
		t.Fatalf("second login status = %d", status)
	}
	if second.Token == "" || second.Token == first.Token {
		t.Fatalf("second login token = %q, first = %q", second.Token, first.Token)
	}
	if second.Code != "" || second.Secret != "" {
		t.Fatalf("second login must not resend Code/Secret, got %q/%q", second.Code, second.Secret)
	}

	status, _ = doJSON(t, http.MethodPost, srv.URL()+"/node/ping", protocol.PingInfo{Time: protocol.NowMillis()}, "")
	if status != http.StatusUnauthorized {
		t.Fatalf("unauthenticated ping status = %d, want 401", status)
	}

	status, data := doJSON(t, http.MethodPost, srv.URL()+"/node/ping", protocol.PingInfo{Time: protocol.NowMillis(), IP: "10.0.0.8"}, second.Token)
	if status != http.StatusOK {
		t.Fatalf("ping status = %d: %s", status, data)
	}
	var ping protocol.PingResponse
	if err := json.Unmarshal(data, &ping); err != nil {
		t.Fatalf("decode ping: %v", err)
	}
	if ping.Period <= 0 || ping.ServerTime <= 0 {
		t.Fatalf("ping response = %+v", ping)
	}

	status, _ = doJSON(t, http.MethodPost, srv.URL()+"/node/upgrade?channel=Release", nil, second.Token)
	if status != http.StatusOK {
		t.Fatalf("upgrade status = %d", status)
	}
	status, _ = doJSON(t, http.MethodPost, srv.URL()+"/node/logout?reason=test", nil, second.Token)
	if status != http.StatusOK {
		t.Fatalf("logout status = %d", status)
	}

	st := srv.State()
	if len(st.Logins) != 3 {
		t.Fatalf("logins recorded = %d, want 3", len(st.Logins))
	}
	if len(st.Pings) != 1 || st.Pings[0].TimeField <= 0 {
		t.Fatalf("pings = %+v", st.Pings)
	}
	if len(st.Upgrades) != 1 || st.Upgrades[0].Channel != "Release" {
		t.Fatalf("upgrades = %+v", st.Upgrades)
	}
	if len(st.Logouts) != 1 || st.Logouts[0].Reason != "test" {
		t.Fatalf("logouts = %+v", st.Logouts)
	}
}

func TestHTTPFallbackCommandsAndEventRetry(t *testing.T) {
	srv := startServer(t, Options{EventsFailures: 2})
	status, loginResp, _ := login(t, srv, "", "")
	if status != http.StatusOK {
		t.Fatalf("login status = %d", status)
	}

	cmd := protocol.CommandModel{Id: 77, Command: "node/synctime"}
	status, _ = doJSON(t, http.MethodPost, srv.URL()+"/control/ping", map[string]any{"Commands": []protocol.CommandModel{cmd}}, "")
	if status != http.StatusOK {
		t.Fatalf("control ping status = %d", status)
	}
	status, data := doJSON(t, http.MethodPost, srv.URL()+"/node/ping", protocol.PingInfo{Time: protocol.NowMillis()}, loginResp.Token)
	if status != http.StatusOK {
		t.Fatalf("ping status = %d: %s", status, data)
	}
	var ping protocol.PingResponse
	if err := json.Unmarshal(data, &ping); err != nil {
		t.Fatalf("decode ping: %v", err)
	}
	if len(ping.Commands) != 1 || ping.Commands[0].Id != 77 {
		t.Fatalf("ping commands = %+v", ping.Commands)
	}

	status, _ = doJSON(t, http.MethodPost, srv.URL()+"/node/commandreply", protocol.CommandReplyModel{Id: 77, Status: protocol.StatusCompleted, Data: "ok"}, loginResp.Token)
	if status != http.StatusOK {
		t.Fatalf("commandreply status = %d", status)
	}

	events := []protocol.EventModel{{Time: protocol.NowMillis(), Type: "info", Name: "Startup"}}
	for i := 0; i < 2; i++ {
		status, _ = doJSON(t, http.MethodPost, srv.URL()+"/node/postevents", events, loginResp.Token)
		if status != http.StatusInternalServerError {
			t.Fatalf("postevents attempt %d status = %d, want 500", i+1, status)
		}
	}
	status, data = doJSON(t, http.MethodPost, srv.URL()+"/node/postevents", events, loginResp.Token)
	if status != http.StatusOK {
		t.Fatalf("postevents status = %d: %s", status, data)
	}
	count, err := protocol.ParseEventCount(data)
	if err != nil || count != 1 {
		t.Fatalf("ParseEventCount(%s) = %d, %v", data, count, err)
	}

	st := srv.State()
	if len(st.Replies) != 1 || st.Replies[0].Reply.Id != 77 {
		t.Fatalf("replies = %+v", st.Replies)
	}
	if len(st.EventAttempts) != 3 || len(st.Events) != 1 {
		t.Fatalf("event attempts=%d events=%d", len(st.EventAttempts), len(st.Events))
	}
}

func TestWebSocketCommandAndPingPong(t *testing.T) {
	srv := startServer(t, Options{AutoPingInterval: 100 * time.Millisecond})
	status, loginResp, _ := login(t, srv, "", "")
	if status != http.StatusOK {
		t.Fatalf("login status = %d", status)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	header := http.Header{}
	header.Set("Authorization", "Bearer "+loginResp.Token)
	conn, _, err := websocket.Dial(ctx, srv.WSURL(), &websocket.DialOptions{HTTPHeader: header})
	if err != nil {
		t.Fatalf("dial ws: %v", err)
	}
	defer conn.CloseNow()

	cmd := protocol.CommandModel{Id: 88, Command: "node/synctime"}
	status, _ = doJSON(t, http.MethodPost, srv.URL()+"/control/command?via=ws", cmd, "")
	if status != http.StatusOK {
		t.Fatalf("control command status = %d", status)
	}

	var got protocol.CommandModel
	for got.Id == 0 {
		typ, data, err := conn.Read(ctx)
		if err != nil {
			t.Fatalf("read ws: %v", err)
		}
		if typ != websocket.MessageText {
			continue
		}
		switch string(data) {
		case "Ping":
			if err := conn.Write(ctx, websocket.MessageText, []byte("Pong")); err != nil {
				t.Fatalf("write pong: %v", err)
			}
		default:
			var parsed protocol.CommandModel
			if err := json.Unmarshal(data, &parsed); err != nil {
				t.Fatalf("decode command %s: %v", data, err)
			}
			got = parsed
		}
	}
	if got.Id != 88 || got.Command != "node/synctime" {
		t.Fatalf("command = %+v", got)
	}

	serverPingSeen := false
	pingDeadline := time.Now().Add(5 * time.Second)
	for !serverPingSeen && time.Now().Before(pingDeadline) {
		typ, data, err := conn.Read(ctx)
		if err != nil {
			t.Fatalf("read ws: %v", err)
		}
		if typ != websocket.MessageText {
			continue
		}
		switch string(data) {
		case "Ping":
			if err := conn.Write(ctx, websocket.MessageText, []byte("Pong")); err != nil {
				t.Fatalf("write pong: %v", err)
			}
			serverPingSeen = true
		}
	}
	if !serverPingSeen {
		t.Fatal("server did not send an application-level Ping")
	}

	if err := conn.Write(ctx, websocket.MessageText, []byte("Ping")); err != nil {
		t.Fatalf("write ping: %v", err)
	}
	pongSeen := false
	pongDeadline := time.Now().Add(5 * time.Second)
	for !pongSeen && time.Now().Before(pongDeadline) {
		typ, data, err := conn.Read(ctx)
		if err != nil {
			t.Fatalf("read ws: %v", err)
		}
		if typ != websocket.MessageText {
			continue
		}
		switch string(data) {
		case "Ping":
			if err := conn.Write(ctx, websocket.MessageText, []byte("Pong")); err != nil {
				t.Fatalf("write pong: %v", err)
			}
		case "Pong":
			pongSeen = true
		}
	}
	if !pongSeen {
		t.Fatal("server did not reply with Pong")
	}
	waitFor(t, 2*time.Second, func() bool { return srv.State().Pongs >= 1 })

	st := srv.State()
	if st.WSConnections != 1 || st.WSActive != 1 {
		t.Fatalf("ws connections=%d active=%d", st.WSConnections, st.WSActive)
	}
	if st.Pongs < 1 {
		t.Fatalf("server did not observe a Pong, pongs=%d", st.Pongs)
	}
	if len(st.Frames) == 0 {
		t.Fatal("no websocket frames recorded")
	}

	status, _ = doJSON(t, http.MethodPost, srv.URL()+"/control/kick", nil, "")
	if status != http.StatusOK {
		t.Fatalf("kick status = %d", status)
	}
	waitFor(t, 2*time.Second, func() bool { return srv.State().WSActive == 0 })
}

func waitFor(t *testing.T, timeout time.Duration, predicate func() bool) {
	t.Helper()
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		if predicate() {
			return
		}
		time.Sleep(20 * time.Millisecond)
	}
	t.Fatal("condition not met before timeout")
}