Files
EasyTier/easytier-go/tests/web_client_e2e_test.go
KKRainbow 3d0c9c3ca5 chore(go): use lowercase module import path (#2560)
* chore(go): use lowercase module import path
* ci: scope checks for Go module changes
2026-09-10 15:13:55 +08:00

255 lines
6.8 KiB
Go

package host_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"strconv"
"strings"
"testing"
"time"
corehost "github.com/easytier/easytier/easytier-go"
)
func TestWebClientEndToEnd(t *testing.T) {
endpoint := os.Getenv("EASYTIER_WEB_E2E_ENDPOINT")
apiURL := os.Getenv("EASYTIER_WEB_E2E_API")
authToken := os.Getenv("EASYTIER_WEB_E2E_AUTH")
if endpoint == "" || apiURL == "" || authToken == "" {
t.Skip("EasyTier WebClient E2E environment is not configured")
}
userID, err := strconv.Atoi(os.Getenv("EASYTIER_WEB_E2E_USER_ID"))
if err != nil || userID == 0 {
userID = 1
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
host, err := corehost.New(ctx, corehost.Options{})
if err != nil {
t.Fatalf("create host: %v", err)
}
defer host.Close(ctx)
application, err := host.CreateInstance(
ctx,
instanceConfig(t, 99, "10.154.0.1", 0, false, false),
)
if err != nil {
t.Fatalf("create application instance: %v", err)
}
defer application.Close(ctx)
const machineID = "11111111-2222-4333-8444-555555555555"
client, err := host.ConnectWebClient(ctx, corehost.WebClientOptions{
Endpoint: endpoint,
MachineID: machineID,
Hostname: "go-host-e2e",
})
if err != nil {
t.Fatalf("connect WebClient: %v", err)
}
defer client.Close(ctx)
waitFor(t, ctx, client.Connected, "WebClient connection")
base := fmt.Sprintf(
"%s/api/internal/users/%d/machines/%s/networks",
strings.TrimRight(apiURL, "/"),
userID,
machineID,
)
waitFor(t, ctx, func() bool {
body, status := webRequest(t, ctx, authToken, http.MethodGet, base, nil)
var response struct {
Running []json.RawMessage `json:"running_inst_ids"`
}
return status == http.StatusOK &&
json.Unmarshal(body, &response) == nil &&
len(response.Running) == 1
}, "application instance heartbeat")
const managedID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
networkName := "go-host-managed"
networkSecret := "test"
managedConfig := map[string]any{
"instance_id": managedID,
"dhcp": true,
"network_name": networkName,
"network_secret": networkSecret,
"networking_method": 2,
"listener_urls": []string{"tcp://0.0.0.0:11010", "udp://0.0.0.0:11010", "wg://0.0.0.0:11011"},
"proxy_cidrs": []string{"10.200.0.0/24"},
"disable_p2p": true,
"disable_ipv6": true,
"enable_vpn_portal": true,
"vpn_portal_listen_port": 11012,
"vpn_portal_client_network_addr": "10.210.0.0",
"vpn_portal_client_network_len": 24,
"data_compress_algo": 2,
"credential_file": "/unsupported",
"enable_quic_proxy": true,
"mapped_listeners": []string{"wg://0.0.0.0:11012"},
"advanced_settings": true,
}
payload, err := json.Marshal(map[string]any{
"config": managedConfig,
"save": false,
})
if err != nil {
t.Fatalf("encode managed network request: %v", err)
}
body, status := webRequest(t, ctx, authToken, http.MethodPost, base, payload)
if status != http.StatusOK {
t.Fatalf("run managed instance: status=%d body=%s", status, body)
}
var managedInstance *corehost.Instance
waitFor(t, ctx, func() bool {
for _, instance := range host.Instances() {
if instance.ID() == managedID {
managedInstance = instance
return true
}
}
return false
}, "managed instance creation")
infoURL := base + "/info"
body, status = webRequest(
t,
ctx,
authToken,
http.MethodGet,
infoURL,
[]byte(fmt.Sprintf(`{"inst_ids":["%s"]}`, managedID)),
)
if status != http.StatusOK ||
!bytes.Contains(body, []byte(managedID)) ||
!bytes.Contains(body, []byte(`"running":true`)) ||
!bytes.Contains(body, []byte("tcp://")) ||
!bytes.Contains(body, []byte("udp://")) ||
bytes.Contains(body, []byte("wg://")) {
t.Fatalf("collect managed status: status=%d body=%s", status, body)
}
portProbe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve port-forward address: %v", err)
}
portForwardAddress := portProbe.Addr().String()
portForwardPort := portProbe.Addr().(*net.TCPAddr).Port
if err := portProbe.Close(); err != nil {
t.Fatalf("release port-forward address: %v", err)
}
managedConfig["proxy_cidrs"] = []string{"10.201.0.0/24"}
managedConfig["disable_relay_data"] = true
managedConfig["port_forwards"] = []map[string]any{{
"proto": "tcp",
"bind_ip": "127.0.0.1",
"bind_port": portForwardPort,
"dst_ip": "10.201.0.1",
"dst_port": 80,
}}
payload, err = json.Marshal(map[string]any{
"managed_network_configs": []map[string]any{{
"instance_id": managedID,
"network_config": managedConfig,
}},
"config_revision": "revision-1",
})
if err != nil {
t.Fatalf("encode managed network update: %v", err)
}
body, status = webRequest(t, ctx, authToken, http.MethodPut, base, payload)
if status != http.StatusOK {
t.Fatalf("update managed instance: status=%d body=%s", status, body)
}
waitFor(t, ctx, func() bool {
connection, err := net.DialTimeout(
"tcp",
portForwardAddress,
100*time.Millisecond,
)
if err != nil {
return false
}
connection.Close()
return true
}, "managed instance hot patch")
for _, instance := range host.Instances() {
if instance.ID() == managedID && instance != managedInstance {
t.Fatal("managed hot patch replaced the running instance")
}
}
body, status = webRequest(
t,
ctx,
authToken,
http.MethodDelete,
base+"/"+managedID,
nil,
)
if status != http.StatusOK {
t.Fatalf("delete managed instance: status=%d body=%s", status, body)
}
waitFor(t, ctx, func() bool {
instances := host.Instances()
return len(instances) == 1 && instances[0].ID() == application.ID()
}, "managed instance deletion")
}
func webRequest(
t *testing.T,
ctx context.Context,
authToken string,
method string,
url string,
body []byte,
) ([]byte, int) {
t.Helper()
request, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
t.Fatalf("create %s request: %v", method, err)
}
request.Header.Set("X-Internal-Auth", authToken)
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, 0
}
defer response.Body.Close()
encoded, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read %s response: %v", method, err)
}
return encoded, response.StatusCode
}
func waitFor(
t *testing.T,
ctx context.Context,
condition func() bool,
description string,
) {
t.Helper()
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
if condition() {
return
}
select {
case <-ticker.C:
case <-ctx.Done():
t.Fatalf("wait for %s: %v", description, ctx.Err())
}
}
}