693 lines
24 KiB
Go
693 lines
24 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"clawreef/internal/models"
|
|
"clawreef/internal/repository"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
func TestInstanceProxyServiceUsesRuntimeBindingForV2(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/instances/123/proxy/apps/openclaw" {
|
|
t.Fatalf("unexpected upstream path %s", r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("X-Forwarded-Prefix"); got != "/api/v1/instances/123/proxy" {
|
|
t.Fatalf("X-Forwarded-Prefix = %q", got)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer internal-openclaw-token" {
|
|
t.Fatalf("Authorization = %q", got)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
podIP, gatewayPort := splitURLHostPortForProxyTest(t, upstream.URL)
|
|
workspacePath := "/workspaces/openclaw/user-45/instance-123"
|
|
instanceRepo := newV2LifecycleInstanceRepo()
|
|
instanceRepo.byID[123] = &models.Instance{
|
|
ID: 123,
|
|
UserID: 45,
|
|
Type: "openclaw",
|
|
RuntimeType: "gateway",
|
|
Status: "running",
|
|
WorkspacePath: &workspacePath,
|
|
RuntimeGeneration: 3,
|
|
}
|
|
bindingRepo := newFakeRuntimeBindingRepo()
|
|
bindingRepo.bindings[123] = &models.InstanceRuntimeBinding{
|
|
InstanceID: 123,
|
|
RuntimePodID: 9,
|
|
GatewayPort: gatewayPort,
|
|
State: "running",
|
|
Generation: 3,
|
|
}
|
|
podRepo := &fakeRuntimePodRepo{
|
|
pods: map[int64]*models.RuntimePod{
|
|
9: {ID: 9, PodIP: &podIP, State: "ready"},
|
|
},
|
|
}
|
|
accessService := NewInstanceAccessService()
|
|
defer accessService.Stop()
|
|
token, err := accessService.GenerateToken(45, 123, "openclaw", "/api/v1/instances/123/proxy/", "", 3000, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken returned error: %v", err)
|
|
}
|
|
service := NewInstanceProxyService(accessService)
|
|
service.instanceRepo = instanceRepo
|
|
service.bindingRepo = bindingRepo
|
|
service.runtimePodRepo = podRepo
|
|
service.httpClient = upstream.Client()
|
|
service.openClawGatewayToken = "internal-openclaw-token"
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/123/proxy/apps/openclaw?token="+url.QueryEscape(token.Token), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
if err := service.ProxyRequest(req.Context(), 123, token.Token, rec, req); err != nil {
|
|
t.Fatalf("ProxyRequest returned error: %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
|
|
t.Fatalf("unexpected proxy response %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestInstanceProxyServiceInjectsInstanceTokenForHermesLite(t *testing.T) {
|
|
instanceToken := "igt_hermes_instance"
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/chat" {
|
|
t.Fatalf("unexpected upstream path %s", r.URL.Path)
|
|
}
|
|
if r.URL.RawQuery != "" {
|
|
t.Fatalf("unexpected upstream query %q", r.URL.RawQuery)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer "+instanceToken {
|
|
t.Fatalf("Authorization = %q", got)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
podIP, gatewayPort := splitURLHostPortForProxyTest(t, upstream.URL)
|
|
workspacePath := "/workspaces/hermes/user-45/instance-127"
|
|
instanceRepo := newV2LifecycleInstanceRepo()
|
|
instanceRepo.byID[127] = &models.Instance{
|
|
ID: 127,
|
|
UserID: 45,
|
|
Type: "hermes",
|
|
RuntimeType: "gateway",
|
|
InstanceMode: InstanceModeLite,
|
|
Status: "running",
|
|
AccessToken: &instanceToken,
|
|
WorkspacePath: &workspacePath,
|
|
RuntimeGeneration: 5,
|
|
}
|
|
bindingRepo := newFakeRuntimeBindingRepo()
|
|
bindingRepo.bindings[127] = &models.InstanceRuntimeBinding{
|
|
InstanceID: 127,
|
|
RuntimePodID: 10,
|
|
GatewayPort: gatewayPort,
|
|
State: "running",
|
|
Generation: 5,
|
|
}
|
|
podRepo := &fakeRuntimePodRepo{
|
|
pods: map[int64]*models.RuntimePod{
|
|
10: {ID: 10, PodIP: &podIP, State: "ready"},
|
|
},
|
|
}
|
|
accessService := NewInstanceAccessService()
|
|
defer accessService.Stop()
|
|
token, err := accessService.GenerateToken(45, 127, "hermes", "/api/v1/instances/127/proxy/", "", 3000, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken returned error: %v", err)
|
|
}
|
|
service := NewInstanceProxyService(accessService)
|
|
service.instanceRepo = instanceRepo
|
|
service.bindingRepo = bindingRepo
|
|
service.runtimePodRepo = podRepo
|
|
service.httpClient = upstream.Client()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/127/proxy/chat?token="+url.QueryEscape(token.Token), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
if err := service.ProxyRequest(req.Context(), 127, token.Token, rec, req); err != nil {
|
|
t.Fatalf("ProxyRequest returned error: %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
|
|
t.Fatalf("unexpected proxy response %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestInstanceProxyServiceUsesHermesLiteAccessURLForRootEntry(t *testing.T) {
|
|
instanceToken := "igt_hermes_instance"
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/chat/" {
|
|
t.Fatalf("unexpected upstream path %s", r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer "+instanceToken {
|
|
t.Fatalf("Authorization = %q", got)
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("<!doctype html><html><head><title>Hermes</title></head><body>ok</body></html>"))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
podIP, gatewayPort := splitURLHostPortForProxyTest(t, upstream.URL)
|
|
workspacePath := "/workspaces/hermes/user-45/instance-131"
|
|
instanceRepo := newV2LifecycleInstanceRepo()
|
|
instanceRepo.byID[131] = &models.Instance{
|
|
ID: 131,
|
|
UserID: 45,
|
|
Type: "hermes",
|
|
RuntimeType: "gateway",
|
|
InstanceMode: InstanceModeLite,
|
|
Status: "running",
|
|
AccessToken: &instanceToken,
|
|
WorkspacePath: &workspacePath,
|
|
RuntimeGeneration: 5,
|
|
}
|
|
bindingRepo := newFakeRuntimeBindingRepo()
|
|
bindingRepo.bindings[131] = &models.InstanceRuntimeBinding{
|
|
InstanceID: 131,
|
|
RuntimePodID: 14,
|
|
GatewayPort: gatewayPort,
|
|
State: "running",
|
|
Generation: 5,
|
|
}
|
|
podRepo := &fakeRuntimePodRepo{
|
|
pods: map[int64]*models.RuntimePod{
|
|
14: {ID: 14, PodIP: &podIP, State: "ready"},
|
|
},
|
|
}
|
|
accessService := NewInstanceAccessService()
|
|
defer accessService.Stop()
|
|
token, err := accessService.GenerateToken(45, 131, "hermes", "/api/v1/instances/131/proxy/chat/", "", 3000, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken returned error: %v", err)
|
|
}
|
|
service := NewInstanceProxyService(accessService)
|
|
service.instanceRepo = instanceRepo
|
|
service.bindingRepo = bindingRepo
|
|
service.runtimePodRepo = podRepo
|
|
service.httpClient = upstream.Client()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/131/proxy/?token="+url.QueryEscape(token.Token), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
if err := service.ProxyRequest(req.Context(), 131, token.Token, rec, req); err != nil {
|
|
t.Fatalf("ProxyRequest returned error: %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("unexpected proxy response %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `<base href="/api/v1/instances/131/proxy/chat/">`) {
|
|
t.Fatalf("expected Hermes Lite HTML to include chat proxy base, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestInstanceProxyServicePreservesHermesRuntimeQueryToken(t *testing.T) {
|
|
instanceToken := "igt_hermes_instance"
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/ws" {
|
|
t.Fatalf("unexpected upstream path %s", r.URL.Path)
|
|
}
|
|
if got := r.URL.Query().Get("token"); got != "hermes-session-token" {
|
|
t.Fatalf("upstream token query = %q", got)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer "+instanceToken {
|
|
t.Fatalf("Authorization = %q", got)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
podIP, gatewayPort := splitURLHostPortForProxyTest(t, upstream.URL)
|
|
workspacePath := "/workspaces/hermes/user-45/instance-130"
|
|
instanceRepo := newV2LifecycleInstanceRepo()
|
|
instanceRepo.byID[130] = &models.Instance{
|
|
ID: 130,
|
|
UserID: 45,
|
|
Type: "hermes",
|
|
RuntimeType: "gateway",
|
|
InstanceMode: InstanceModeLite,
|
|
Status: "running",
|
|
AccessToken: &instanceToken,
|
|
WorkspacePath: &workspacePath,
|
|
RuntimeGeneration: 5,
|
|
}
|
|
bindingRepo := newFakeRuntimeBindingRepo()
|
|
bindingRepo.bindings[130] = &models.InstanceRuntimeBinding{
|
|
InstanceID: 130,
|
|
RuntimePodID: 13,
|
|
GatewayPort: gatewayPort,
|
|
State: "running",
|
|
Generation: 5,
|
|
}
|
|
podRepo := &fakeRuntimePodRepo{
|
|
pods: map[int64]*models.RuntimePod{
|
|
13: {ID: 13, PodIP: &podIP, State: "ready"},
|
|
},
|
|
}
|
|
accessService := NewInstanceAccessService()
|
|
defer accessService.Stop()
|
|
token, err := accessService.GenerateToken(45, 130, "hermes", "/api/v1/instances/130/proxy/chat/", "", 3000, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken returned error: %v", err)
|
|
}
|
|
service := NewInstanceProxyService(accessService)
|
|
service.instanceRepo = instanceRepo
|
|
service.bindingRepo = bindingRepo
|
|
service.runtimePodRepo = podRepo
|
|
service.httpClient = upstream.Client()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/130/proxy/api/ws?token=hermes-session-token", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
if err := service.ProxyRequest(req.Context(), 130, token.Token, rec, req); err != nil {
|
|
t.Fatalf("ProxyRequest returned error: %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
|
|
t.Fatalf("unexpected proxy response %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestInstanceProxyServiceRewritesHermesLiteHTMLBase(t *testing.T) {
|
|
instanceToken := "igt_hermes_instance"
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
t.Fatalf("unexpected upstream path %s", r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer "+instanceToken {
|
|
t.Fatalf("Authorization = %q", got)
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("<!doctype html><html><head><title>Hermes</title></head><body>ok</body></html>"))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
podIP, gatewayPort := splitURLHostPortForProxyTest(t, upstream.URL)
|
|
workspacePath := "/workspaces/hermes/user-45/instance-128"
|
|
instanceRepo := newV2LifecycleInstanceRepo()
|
|
instanceRepo.byID[128] = &models.Instance{
|
|
ID: 128,
|
|
UserID: 45,
|
|
Type: "hermes",
|
|
RuntimeType: "gateway",
|
|
InstanceMode: InstanceModeLite,
|
|
Status: "running",
|
|
AccessToken: &instanceToken,
|
|
WorkspacePath: &workspacePath,
|
|
RuntimeGeneration: 5,
|
|
}
|
|
bindingRepo := newFakeRuntimeBindingRepo()
|
|
bindingRepo.bindings[128] = &models.InstanceRuntimeBinding{
|
|
InstanceID: 128,
|
|
RuntimePodID: 11,
|
|
GatewayPort: gatewayPort,
|
|
State: "running",
|
|
Generation: 5,
|
|
}
|
|
podRepo := &fakeRuntimePodRepo{
|
|
pods: map[int64]*models.RuntimePod{
|
|
11: {ID: 11, PodIP: &podIP, State: "ready"},
|
|
},
|
|
}
|
|
accessService := NewInstanceAccessService()
|
|
defer accessService.Stop()
|
|
token, err := accessService.GenerateToken(45, 128, "hermes", "/api/v1/instances/128/proxy/", "", 3000, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken returned error: %v", err)
|
|
}
|
|
service := NewInstanceProxyService(accessService)
|
|
service.instanceRepo = instanceRepo
|
|
service.bindingRepo = bindingRepo
|
|
service.runtimePodRepo = podRepo
|
|
service.httpClient = upstream.Client()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/128/proxy/?token="+url.QueryEscape(token.Token), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
if err := service.ProxyRequest(req.Context(), 128, token.Token, rec, req); err != nil {
|
|
t.Fatalf("ProxyRequest returned error: %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("unexpected proxy response %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `<base href="/api/v1/instances/128/proxy/">`) {
|
|
t.Fatalf("expected Hermes Lite HTML to include proxy base, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestInstanceProxyServiceProxiesHermesLiteWebSocket(t *testing.T) {
|
|
instanceToken := "igt_hermes_instance"
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/ws" {
|
|
t.Fatalf("unexpected upstream websocket path %s", r.URL.Path)
|
|
}
|
|
if got := r.URL.Query().Get("token"); got != "hermes-ws-token" {
|
|
t.Fatalf("upstream websocket token query = %q", got)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer "+instanceToken {
|
|
t.Fatalf("Authorization = %q", got)
|
|
}
|
|
if got := r.Header.Get("X-Forwarded-Prefix"); got != "/api/v1/instances/129/proxy" {
|
|
t.Fatalf("X-Forwarded-Prefix = %q", got)
|
|
}
|
|
if got := r.Header.Get("X-Forwarded-Host"); got == "" {
|
|
t.Fatalf("X-Forwarded-Host is empty")
|
|
}
|
|
if got := r.Header.Get("X-Forwarded-For"); got == "" {
|
|
t.Fatalf("X-Forwarded-For is empty")
|
|
}
|
|
if got, want := r.Header.Get("Origin"), "http://"+r.Host; got != want {
|
|
t.Fatalf("Origin = %q, want %q", got, want)
|
|
}
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Fatalf("upstream websocket upgrade failed: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
messageType, message, err := conn.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("upstream websocket read failed: %v", err)
|
|
}
|
|
if string(message) != "ping" {
|
|
t.Fatalf("upstream websocket message = %q", message)
|
|
}
|
|
if err := conn.WriteMessage(messageType, []byte("pong")); err != nil {
|
|
t.Fatalf("upstream websocket write failed: %v", err)
|
|
}
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
podIP, gatewayPort := splitURLHostPortForProxyTest(t, upstream.URL)
|
|
workspacePath := "/workspaces/hermes/user-45/instance-129"
|
|
instanceRepo := newV2LifecycleInstanceRepo()
|
|
instanceRepo.byID[129] = &models.Instance{
|
|
ID: 129,
|
|
UserID: 45,
|
|
Type: "hermes",
|
|
RuntimeType: "gateway",
|
|
InstanceMode: InstanceModeLite,
|
|
Status: "running",
|
|
AccessToken: &instanceToken,
|
|
WorkspacePath: &workspacePath,
|
|
RuntimeGeneration: 5,
|
|
}
|
|
bindingRepo := newFakeRuntimeBindingRepo()
|
|
bindingRepo.bindings[129] = &models.InstanceRuntimeBinding{
|
|
InstanceID: 129,
|
|
RuntimePodID: 12,
|
|
GatewayPort: gatewayPort,
|
|
State: "running",
|
|
Generation: 5,
|
|
}
|
|
podRepo := &fakeRuntimePodRepo{
|
|
pods: map[int64]*models.RuntimePod{
|
|
12: {ID: 12, PodIP: &podIP, State: "ready"},
|
|
},
|
|
}
|
|
accessService := NewInstanceAccessService()
|
|
defer accessService.Stop()
|
|
token, err := accessService.GenerateToken(45, 129, "hermes", "/api/v1/instances/129/proxy/", "", 3000, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken returned error: %v", err)
|
|
}
|
|
service := NewInstanceProxyService(accessService)
|
|
service.instanceRepo = instanceRepo
|
|
service.bindingRepo = bindingRepo
|
|
service.runtimePodRepo = podRepo
|
|
|
|
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := service.ProxyWebSocket(r.Context(), 129, token.Token, w, r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
}
|
|
}))
|
|
defer proxy.Close()
|
|
|
|
wsURL := "ws" + strings.TrimPrefix(proxy.URL, "http") + "/api/v1/instances/129/proxy/ws?token=hermes-ws-token"
|
|
clientConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("client websocket dial failed: %v", err)
|
|
}
|
|
defer clientConn.Close()
|
|
if err := clientConn.WriteMessage(websocket.TextMessage, []byte("ping")); err != nil {
|
|
t.Fatalf("client websocket write failed: %v", err)
|
|
}
|
|
_, message, err := clientConn.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("client websocket read failed: %v", err)
|
|
}
|
|
if string(message) != "pong" {
|
|
t.Fatalf("client websocket message = %q", message)
|
|
}
|
|
}
|
|
|
|
func TestInstanceProxyServiceUsesBaseProxyEntryForV2OpenClaw(t *testing.T) {
|
|
workspacePath := "/workspaces/openclaw/user-45/instance-123"
|
|
accessService := NewInstanceAccessService()
|
|
t.Cleanup(accessService.Stop)
|
|
service := NewInstanceProxyService(accessService)
|
|
got := service.GetProxyURLForInstance(&models.Instance{
|
|
ID: 123,
|
|
Type: "openclaw",
|
|
RuntimeType: "gateway",
|
|
WorkspacePath: &workspacePath,
|
|
}, "token+with/slash")
|
|
|
|
want := "/api/v1/instances/123/proxy/?token=token%2Bwith%2Fslash"
|
|
if got != want {
|
|
t.Fatalf("GetProxyURLForInstance() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestInstanceProxyServiceUsesChatEntryForHermesLite(t *testing.T) {
|
|
workspacePath := "/workspaces/hermes/user-45/instance-123"
|
|
accessService := NewInstanceAccessService()
|
|
t.Cleanup(accessService.Stop)
|
|
service := NewInstanceProxyService(accessService)
|
|
got := service.GetProxyURLForInstance(&models.Instance{
|
|
ID: 123,
|
|
Type: "hermes",
|
|
RuntimeType: "gateway",
|
|
InstanceMode: InstanceModeLite,
|
|
WorkspacePath: &workspacePath,
|
|
}, "token+with/slash")
|
|
|
|
want := "/api/v1/instances/123/proxy/chat/?token=token%2Bwith%2Fslash"
|
|
if got != want {
|
|
t.Fatalf("GetProxyURLForInstance() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestProxyBaseForRequestPathUsesSubdirectory(t *testing.T) {
|
|
got := proxyBaseForRequestPath("/api/v1/instances/123/proxy/__openclaw__/canvas/", 123)
|
|
want := "/api/v1/instances/123/proxy/__openclaw__/canvas/"
|
|
if got != want {
|
|
t.Fatalf("proxyBaseForRequestPath() = %q, want %q", got, want)
|
|
}
|
|
|
|
got = proxyBaseForRequestPath("/api/v1/instances/123/proxy/__openclaw__/canvas/app.js", 123)
|
|
if got != want {
|
|
t.Fatalf("proxyBaseForRequestPath(file) = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestResolveOpenClawProxyOriginFromEnvUsesInternalOrigin(t *testing.T) {
|
|
t.Setenv("OPENCLAW_PROXY_ORIGIN", "")
|
|
t.Setenv("CLAWMANAGER_BACKEND_URL", "")
|
|
t.Setenv("CLAWMANAGER_TEAM_MANAGER_BASE_URL", "http://clawmanager-gateway.clawmanager-system.svc.cluster.local:9001/api")
|
|
|
|
got := resolveOpenClawProxyOriginFromEnv()
|
|
want := "http://clawmanager-gateway.clawmanager-system.svc.cluster.local:9001"
|
|
if got != want {
|
|
t.Fatalf("resolveOpenClawProxyOriginFromEnv() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestOpenClawWebSocketOriginPrefersConfiguredProxyOrigin(t *testing.T) {
|
|
accessService := NewInstanceAccessService()
|
|
t.Cleanup(accessService.Stop)
|
|
service := NewInstanceProxyService(accessService)
|
|
service.openClawProxyOrigin = "http://clawmanager-gateway.clawmanager-system.svc.cluster.local:9001"
|
|
|
|
got := service.openClawWebSocketOrigin(&url.URL{Scheme: "ws", Host: "10.42.0.63:20000"})
|
|
want := "http://clawmanager-gateway.clawmanager-system.svc.cluster.local:9001"
|
|
if got != want {
|
|
t.Fatalf("openClawWebSocketOrigin() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestWebSocketUpstreamOriginFallsBackToGatewayHost(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url *url.URL
|
|
want string
|
|
}{
|
|
{
|
|
name: "plain websocket",
|
|
url: &url.URL{Scheme: "ws", Host: "10.42.0.63:20000"},
|
|
want: "http://10.42.0.63:20000",
|
|
},
|
|
{
|
|
name: "tls websocket",
|
|
url: &url.URL{Scheme: "wss", Host: "gateway.example.test"},
|
|
want: "https://gateway.example.test",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := websocketUpstreamOrigin(tt.url); got != tt.want {
|
|
t.Fatalf("websocketUpstreamOrigin() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInstanceProxyServiceRejectsStoppedV2InstanceWithStaleBinding(t *testing.T) {
|
|
workspacePath := "/workspaces/openclaw/user-45/instance-125"
|
|
instanceRepo := newV2LifecycleInstanceRepo()
|
|
instanceRepo.byID[125] = &models.Instance{
|
|
ID: 125,
|
|
UserID: 45,
|
|
Type: "openclaw",
|
|
RuntimeType: "gateway",
|
|
Status: "stopped",
|
|
WorkspacePath: &workspacePath,
|
|
RuntimeGeneration: 4,
|
|
}
|
|
bindingRepo := newFakeRuntimeBindingRepo()
|
|
bindingRepo.bindings[125] = &models.InstanceRuntimeBinding{
|
|
InstanceID: 125,
|
|
RuntimePodID: 9,
|
|
GatewayPort: 20025,
|
|
State: "running",
|
|
Generation: 4,
|
|
}
|
|
podIP := "10.42.0.125"
|
|
service, token := newV2ProxyTestService(t, instanceRepo, bindingRepo, &fakeRuntimePodRepo{
|
|
pods: map[int64]*models.RuntimePod{
|
|
9: {ID: 9, PodIP: &podIP},
|
|
},
|
|
}, 45, 125, "openclaw")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/125/proxy/", nil)
|
|
err := service.ProxyRequest(req.Context(), 125, token.Token, httptest.NewRecorder(), req)
|
|
if !errors.Is(err, ErrInstanceGatewayUnavailable) {
|
|
t.Fatalf("ProxyRequest error = %v, want ErrInstanceGatewayUnavailable", err)
|
|
}
|
|
}
|
|
|
|
func TestInstanceProxyServiceRejectsV2BindingGenerationMismatch(t *testing.T) {
|
|
workspacePath := "/workspaces/hermes/user-45/instance-126"
|
|
instanceRepo := newV2LifecycleInstanceRepo()
|
|
instanceRepo.byID[126] = &models.Instance{
|
|
ID: 126,
|
|
UserID: 45,
|
|
Type: "hermes",
|
|
RuntimeType: "gateway",
|
|
Status: "running",
|
|
WorkspacePath: &workspacePath,
|
|
RuntimeGeneration: 8,
|
|
}
|
|
bindingRepo := newFakeRuntimeBindingRepo()
|
|
bindingRepo.bindings[126] = &models.InstanceRuntimeBinding{
|
|
InstanceID: 126,
|
|
RuntimePodID: 10,
|
|
GatewayPort: 20026,
|
|
State: "running",
|
|
Generation: 7,
|
|
}
|
|
podIP := "10.42.0.126"
|
|
service, token := newV2ProxyTestService(t, instanceRepo, bindingRepo, &fakeRuntimePodRepo{
|
|
pods: map[int64]*models.RuntimePod{
|
|
10: {ID: 10, PodIP: &podIP},
|
|
},
|
|
}, 45, 126, "hermes")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/126/proxy/", nil)
|
|
err := service.ProxyRequest(req.Context(), 126, token.Token, httptest.NewRecorder(), req)
|
|
if !errors.Is(err, ErrInstanceGatewayUnavailable) {
|
|
t.Fatalf("ProxyRequest error = %v, want ErrInstanceGatewayUnavailable", err)
|
|
}
|
|
}
|
|
|
|
func TestInstanceProxyServiceReturnsUnavailableWhenV2BindingMissing(t *testing.T) {
|
|
workspacePath := "/workspaces/hermes/user-45/instance-124"
|
|
instanceRepo := newV2LifecycleInstanceRepo()
|
|
instanceRepo.byID[124] = &models.Instance{
|
|
ID: 124,
|
|
UserID: 45,
|
|
Type: "hermes",
|
|
RuntimeType: "gateway",
|
|
Status: "running",
|
|
WorkspacePath: &workspacePath,
|
|
}
|
|
accessService := NewInstanceAccessService()
|
|
defer accessService.Stop()
|
|
token, err := accessService.GenerateToken(45, 124, "hermes", "/api/v1/instances/124/proxy/", "", 3000, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken returned error: %v", err)
|
|
}
|
|
service := NewInstanceProxyService(accessService)
|
|
service.instanceRepo = instanceRepo
|
|
service.bindingRepo = newFakeRuntimeBindingRepo()
|
|
service.runtimePodRepo = &fakeRuntimePodRepo{}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/124/proxy/", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
err = service.ProxyRequest(req.Context(), 124, token.Token, rec, req)
|
|
if !errors.Is(err, ErrInstanceGatewayUnavailable) {
|
|
t.Fatalf("ProxyRequest error = %v, want ErrInstanceGatewayUnavailable", err)
|
|
}
|
|
}
|
|
|
|
func newV2ProxyTestService(t *testing.T, instanceRepo repository.InstanceRepository, bindingRepo repository.InstanceRuntimeBindingRepository, podRepo repository.RuntimePodRepository, userID int, instanceID int, instanceType string) (*InstanceProxyService, *AccessToken) {
|
|
t.Helper()
|
|
accessService := NewInstanceAccessService()
|
|
t.Cleanup(accessService.Stop)
|
|
token, err := accessService.GenerateToken(userID, instanceID, instanceType, "/api/v1/instances/"+strconv.Itoa(instanceID)+"/proxy/", "", 3000, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken returned error: %v", err)
|
|
}
|
|
service := NewInstanceProxyService(accessService)
|
|
service.instanceRepo = instanceRepo
|
|
service.bindingRepo = bindingRepo
|
|
service.runtimePodRepo = podRepo
|
|
return service, token
|
|
}
|
|
|
|
func splitURLHostPortForProxyTest(t *testing.T, rawURL string) (string, int) {
|
|
t.Helper()
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
t.Fatalf("parse upstream URL: %v", err)
|
|
}
|
|
host, portText, err := net.SplitHostPort(parsed.Host)
|
|
if err != nil {
|
|
t.Fatalf("split upstream host port: %v", err)
|
|
}
|
|
port, err := strconv.Atoi(portText)
|
|
if err != nil {
|
|
t.Fatalf("parse upstream port: %v", err)
|
|
}
|
|
return host, port
|
|
}
|