Files
yuan-lab-llm--clawmanager/backend/internal/handlers/instance_handler_test.go
T
wehub-resource-sync 6d978fe483
Build / Frontend Build (push) Has been cancelled
Build / Backend Build (push) Has been cancelled
Build / Docker Build (push) Has been cancelled
Build / Kubernetes Deploy Smoke Test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:24 +08:00

326 lines
11 KiB
Go

package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"clawreef/internal/models"
"clawreef/internal/services"
"github.com/gin-gonic/gin"
)
func TestWorkspaceArchiveMaxMiB(t *testing.T) {
t.Setenv(workspaceArchiveMaxMiBEnv, "")
if got := workspaceArchiveMaxMiB(); got != defaultWorkspaceArchiveMaxMiB {
t.Fatalf("expected default archive limit %d MiB, got %d", defaultWorkspaceArchiveMaxMiB, got)
}
t.Setenv(workspaceArchiveMaxMiBEnv, "750")
if got := workspaceArchiveMaxMiB(); got != 750 {
t.Fatalf("expected env archive limit 750 MiB, got %d", got)
}
t.Setenv(workspaceArchiveMaxMiBEnv, "0")
if got := workspaceArchiveMaxMiB(); got != defaultWorkspaceArchiveMaxMiB {
t.Fatalf("expected invalid archive limit to fall back to %d MiB, got %d", defaultWorkspaceArchiveMaxMiB, got)
}
t.Setenv(workspaceArchiveMaxMiBEnv, "not-a-number")
if got := workspaceArchiveMaxMiB(); got != defaultWorkspaceArchiveMaxMiB {
t.Fatalf("expected unparsable archive limit to fall back to %d MiB, got %d", defaultWorkspaceArchiveMaxMiB, got)
}
}
func TestDesktopAccessUpstreamSkipsDirectProxyForRuntimeGateway(t *testing.T) {
t.Setenv(desktopDirectProxyEnv, "true")
gin.SetMode(gin.TestMode)
tests := []struct {
name string
instance *models.Instance
}{
{
name: "gateway runtime type",
instance: &models.Instance{
ID: 50,
UserID: 1,
Type: "openclaw",
RuntimeType: "gateway",
},
},
{
name: "lite instance mode",
instance: &models.Instance{
ID: 51,
UserID: 1,
Type: "openclaw",
InstanceMode: services.InstanceModeLite,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/instances/50/access", nil)
upstream, directEnabled := (&InstanceHandler{}).desktopAccessUpstream(c, tt.instance, 3001)
if upstream != "" || directEnabled {
t.Fatalf("desktopAccessUpstream() = upstream %q direct %t, want control-plane fallback", upstream, directEnabled)
}
})
}
}
func TestBuildUserSafeInstanceStatusHidesRuntimeSchedulingDetails(t *testing.T) {
podName := "runtime-openclaw-abc"
podNamespace := "clawmanager-system"
podIP := "10.42.0.12"
startedAt := time.Now().UTC()
workspacePath := "/workspaces/openclaw/user-45/instance-123"
instance := &models.Instance{
ID: 123,
Type: "openclaw",
RuntimeType: "gateway",
Status: "running",
WorkspacePath: &workspacePath,
WorkspaceUsageBytes: 123456,
}
status := &services.InstanceStatus{
InstanceID: 123,
Status: "running",
PodName: &podName,
PodNamespace: &podNamespace,
PodIP: &podIP,
PodStatus: "Running",
StartedAt: &startedAt,
}
payload := buildUserSafeInstanceStatus(instance, status)
for _, forbiddenKey := range []string{"pod_name", "pod_namespace", "pod_ip", "pod_status", "gateway_port", "capacity", "node_name"} {
if _, exists := payload[forbiddenKey]; exists {
t.Fatalf("user-safe status exposed %q: %#v", forbiddenKey, payload)
}
}
if payload["availability"] != "available" {
t.Fatalf("availability = %v, want available", payload["availability"])
}
if payload["agent_type"] != "openclaw" {
t.Fatalf("agent_type = %v, want openclaw", payload["agent_type"])
}
if payload["workspace_usage_bytes"] != int64(123456) {
t.Fatalf("workspace_usage_bytes = %v, want 123456", payload["workspace_usage_bytes"])
}
}
func TestShortExternalAccessProxyPath(t *testing.T) {
tests := []struct {
name string
requestPath string
want string
}{
{name: "entry without slash", requestPath: "/s/abc123", want: "/api/v1/instances/71/proxy/"},
{name: "entry with slash", requestPath: "/s/abc123/", want: "/api/v1/instances/71/proxy/"},
{name: "asset", requestPath: "/s/abc123/assets/index.js", want: "/api/v1/instances/71/proxy/assets/index.js"},
{name: "nested", requestPath: "/s/abc123/apps/openclaw/settings", want: "/api/v1/instances/71/proxy/apps/openclaw/settings"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shortExternalAccessProxyPath(tt.requestPath, "abc123", 71); got != tt.want {
t.Fatalf("shortExternalAccessProxyPath(%q) = %q, want %q", tt.requestPath, got, tt.want)
}
})
}
}
func TestShortExternalAccessEntryRedirectTarget(t *testing.T) {
tests := []struct {
name string
method string
requestPath string
code string
canonicalPath string
want string
}{
{
name: "html entry redirects to canonical proxy path",
method: http.MethodGet,
requestPath: "/s/sl_abc123/",
code: "sl_abc123",
canonicalPath: "/api/v1/instances/71/proxy/chat/",
want: "/api/v1/instances/71/proxy/chat/",
},
{
name: "entry without trailing slash redirects",
method: http.MethodGet,
requestPath: "/s/sl_abc123",
code: "sl_abc123",
canonicalPath: "/api/v1/instances/71/proxy/chat/",
want: "/api/v1/instances/71/proxy/chat/",
},
{
name: "asset path keeps short proxy handling",
method: http.MethodGet,
requestPath: "/s/sl_abc123/assets/index.js",
code: "sl_abc123",
canonicalPath: "/api/v1/instances/71/proxy/chat/",
want: "",
},
{
name: "post keeps password form handling",
method: http.MethodPost,
requestPath: "/s/sl_abc123/",
code: "sl_abc123",
canonicalPath: "/api/v1/instances/71/proxy/chat/",
want: "",
},
{
name: "target strips accidental token query",
method: http.MethodGet,
requestPath: "/s/sl_abc123/",
code: "sl_abc123",
canonicalPath: "/api/v1/instances/71/proxy/chat/?token=secret",
want: "/api/v1/instances/71/proxy/chat/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shortExternalAccessEntryRedirectTarget(tt.method, tt.requestPath, tt.code, tt.canonicalPath); got != tt.want {
t.Fatalf("shortExternalAccessEntryRedirectTarget() = %q, want %q", got, tt.want)
}
})
}
}
func TestRenderShortLinkPasswordForm(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/s/sl_abc123/", nil)
renderShortLinkPasswordForm(c, "sl_abc123", "")
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
body := recorder.Body.String()
for _, want := range []string{
"Share link password",
`name="password"`,
`type="password"`,
`action="/s/sl_abc123/"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("password form missing %q in body:\n%s", want, body)
}
}
if contentType := recorder.Header().Get("Content-Type"); !strings.Contains(contentType, "text/html") {
t.Fatalf("content type = %q, want text/html", contentType)
}
}
func TestProxyAccessTokenPrefersCookieOverRuntimeQueryToken(t *testing.T) {
gin.SetMode(gin.TestMode)
accessService := services.NewInstanceAccessService()
defer accessService.Stop()
token, err := accessService.GenerateToken(1, 76, "hermes", "/api/v1/instances/76/proxy/chat/", "", 3000, time.Hour)
if err != nil {
t.Fatalf("GenerateToken returned error: %v", err)
}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/instances/76/proxy/api/ws?token=hermes-session-token", nil)
c.Request.AddCookie(&http.Cookie{Name: "instance_access_76", Value: token.Token})
handler := &InstanceHandler{accessService: accessService}
got, ok := handler.proxyAccessToken(c, 76)
if !ok {
t.Fatal("proxyAccessToken rejected valid cookie")
}
if got != token.Token {
t.Fatalf("proxyAccessToken = %q, want cookie token", got)
}
if recorder.Code != http.StatusOK {
t.Fatalf("unexpected response status %d", recorder.Code)
}
}
func TestProxyAccessTokenRejectsRuntimeQueryTokenWithoutCookie(t *testing.T) {
gin.SetMode(gin.TestMode)
accessService := services.NewInstanceAccessService()
defer accessService.Stop()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/instances/76/proxy/api/ws?token=hermes-session-token", nil)
handler := &InstanceHandler{accessService: accessService}
if got, ok := handler.proxyAccessToken(c, 76); ok || got != "" {
t.Fatalf("proxyAccessToken = %q/%v, want rejected", got, ok)
}
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
}
}
func TestBuildLiteBatchCreateRequestsDefaultsToGatewayLite(t *testing.T) {
requests, handlerRequests, err := buildLiteBatchCreateRequests(BatchCreateLiteInstancesRequest{
NamePrefix: "batch-lite",
Count: 2,
})
if err != nil {
t.Fatalf("buildLiteBatchCreateRequests returned error: %v", err)
}
if len(requests) != 2 || len(handlerRequests) != 2 {
t.Fatalf("request count = %d/%d, want 2/2", len(requests), len(handlerRequests))
}
for idx, req := range requests {
if req.Name == "" || req.Mode != services.InstanceModeLite || req.InstanceMode != services.InstanceModeLite || req.RuntimeType != services.RuntimeBackendGateway {
t.Fatalf("request %d was not normalized to lite gateway: %#v", idx, req)
}
if req.Type != "openclaw" || req.OSType != "openclaw" || req.OSVersion != "latest" {
t.Fatalf("request %d defaults = type %q os %q version %q", idx, req.Type, req.OSType, req.OSVersion)
}
}
}
func TestBatchDeleteLiteInstancesRejectsProInstance(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/instances/batch/delete", strings.NewReader(`{"instance_ids":[42]}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("userID", 7)
c.Set("userRole", "user")
handler := &InstanceHandler{
instanceService: &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
42: {
ID: 42,
UserID: 7,
Name: "pro-openclaw",
RuntimeType: services.RuntimeBackendDesktop,
InstanceMode: services.InstanceModePro,
},
}},
}
handler.BatchDeleteLiteInstances(c)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "not a lite instance") {
t.Fatalf("response did not explain lite-only rejection: %s", recorder.Body.String())
}
}