package main import ( "context" "encoding/json" "fmt" "net" "net/http" "net/http/httptest" "os" "os/exec" "strings" "testing" "time" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/adaptor" ) func TestGatewayCreatesConstrainedBrowser(t *testing.T) { var created map[string]any docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodPost && request.URL.Path == "/v1.43/containers/create": if got := request.URL.Query().Get("name"); got != namePrefix+"account-a" { t.Fatalf("unexpected container name %q", got) } if err := json.NewDecoder(request.Body).Decode(&created); err != nil { t.Fatal(err) } response.WriteHeader(http.StatusCreated) _, _ = response.Write([]byte(`{"Id":"container-id"}`)) case request.Method == http.MethodPost && request.URL.Path == "/v1.43/containers/container-id/start": response.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } })) defer docker.Close() handler := newGateway(dockerClient{baseURL: docker.URL + "/v1.43", client: docker.Client()}, browserConfig{ image: "registry.example/browser:1.2.3", network: "creatorhub_browser", }) request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`)) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, request) if response.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String()) } if created["Image"] != "registry.example/browser:1.2.3" { t.Fatalf("gateway accepted an unexpected image: %#v", created["Image"]) } if created["User"] != browserUser || created["Entrypoint"].([]any)[0] != browserEntrypoint { t.Fatalf("runtime identity is not fixed: user=%#v entrypoint=%#v", created["User"], created["Entrypoint"]) } host := created["HostConfig"].(map[string]any) if host["NetworkMode"] != "creatorhub_browser" || host["ReadonlyRootfs"] != true { t.Fatalf("missing container isolation: %#v", host) } tmpfs := host["Tmpfs"].(map[string]any) if tmpfs["/tmp/.X11-unix"] == nil || tmpfs["/home/ubuntu"] == nil { t.Fatalf("missing writable runtime paths: %#v", tmpfs) } labels := created["Labels"].(map[string]any) if labels[managedLabel] != "true" || labels[idLabel] != "account-a" { t.Fatalf("missing ownership labels: %#v", labels) } } func TestGatewayRejectsOversizedCreateRequest(t *testing.T) { handler := newGateway(dockerClient{}, browserConfig{}) request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(strings.Repeat("x", (1<<20)+1))) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, request) if response.Code != http.StatusRequestEntityTooLarge { t.Fatalf("expected 413 for oversized body, status=%d body=%s", response.Code, response.Body.String()) } handler.Post("/request-limit", func(fiber.Ctx) error { return fiber.ErrRequestEntityTooLarge }) jsonResponse, err := handler.Test(httptest.NewRequest(http.MethodPost, "/request-limit", nil)) if err != nil { t.Fatal(err) } defer jsonResponse.Body.Close() var body map[string]string decodeErr := json.NewDecoder(jsonResponse.Body).Decode(&body) contentType := jsonResponse.Header.Get("Content-Type") if jsonResponse.StatusCode != http.StatusRequestEntityTooLarge || decodeErr != nil || body["error"] == "" || !strings.HasPrefix(contentType, "application/json") { t.Fatalf("expected JSON 413 envelope, status=%d body=%v decode=%v content-type=%q", jsonResponse.StatusCode, body, decodeErr, contentType) } } func TestGatewayRemovesContainerWhenCreateResponseHasNoID(t *testing.T) { removed := false docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodPost && request.URL.Path == "/containers/create": response.WriteHeader(http.StatusCreated) _, _ = response.Write([]byte(`{"Id":""}`)) case request.Method == http.MethodDelete && request.URL.Path == "/containers/"+namePrefix+"account-a": removed = request.URL.Query().Get("force") == "1" && request.URL.Query().Get("v") == "0" response.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } })) defer docker.Close() handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{image: browserImage, network: "creatorhub_browser"}) request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`)) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, request) if response.Code != http.StatusBadGateway || !removed { t.Fatalf("expected invalid create response cleanup, status=%d removed=%v body=%s", response.Code, removed, response.Body.String()) } } func TestGatewayRemovesFailedContainerAndPreservesProfile(t *testing.T) { removed := false docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodPost && request.URL.Path == "/containers/create": response.WriteHeader(http.StatusCreated) _, _ = response.Write([]byte(`{"Id":"failed-id"}`)) case request.Method == http.MethodPost && request.URL.Path == "/containers/failed-id/start": http.Error(response, "start failed", http.StatusInternalServerError) case request.Method == http.MethodDelete && request.URL.Path == "/containers/failed-id": removed = request.URL.Query().Get("force") == "1" && request.URL.Query().Get("v") == "0" response.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } })) defer docker.Close() handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{image: browserImage, network: "creatorhub_browser"}) request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`)) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, request) if response.Code != http.StatusBadGateway || !removed { t.Fatalf("expected failed container cleanup with preserved volume, status=%d removed=%v body=%s", response.Code, removed, response.Body.String()) } } func TestGatewayListsBrowsers(t *testing.T) { docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if request.Method != http.MethodGet || request.URL.Path != "/containers/json" { t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } _, _ = response.Write([]byte(`[{"Id":"container-id","State":"running","Status":"Up","Labels":{"io.creatorhub.runtime-id":"account-a"}}]`)) })) defer docker.Close() handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{}) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/browsers", nil)) var browsers []browser if response.Code != http.StatusOK || json.NewDecoder(response.Body).Decode(&browsers) != nil || len(browsers) != 1 || browsers[0].Name != "account-a" { t.Fatalf("unexpected list response status=%d body=%s", response.Code, response.Body.String()) } } func TestGatewayLifecycle(t *testing.T) { tests := []struct { method string path string dockerPath string }{ {http.MethodPost, "/v1/browsers/account-a/start", "/containers/creatorhub-browser-account-a/start"}, {http.MethodPost, "/v1/browsers/account-a/stop", "/containers/creatorhub-browser-account-a/stop"}, {http.MethodDelete, "/v1/browsers/account-a", "/containers/creatorhub-browser-account-a"}, } for _, test := range tests { t.Run(test.method+" "+test.path, func(t *testing.T) { docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if request.Method == http.MethodGet { _, _ = response.Write([]byte(`{"Config":{"Labels":{"io.creatorhub.managed":"true","io.creatorhub.runtime-id":"account-a"}}}`)) return } if request.URL.Path != test.dockerPath { t.Fatalf("unexpected Docker path %s", request.URL.String()) } response.WriteHeader(http.StatusNoContent) })) defer docker.Close() handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{}) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil)) if response.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String()) } }) } } func TestGatewayMapsDockerServiceFailureToBadGateway(t *testing.T) { docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { http.Error(response, "daemon unavailable", http.StatusInternalServerError) })) defer docker.Close() handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{}) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/v1/browsers/account-a", nil)) if response.Code != http.StatusBadGateway { t.Fatalf("expected 502 for Docker failure, got %d: %s", response.Code, response.Body.String()) } } func TestGatewayRefusesUnmanagedContainer(t *testing.T) { deleted := false docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { switch request.Method { case http.MethodGet: _, _ = response.Write([]byte(`{"Config":{"Labels":{}}}`)) case http.MethodDelete: deleted = true response.WriteHeader(http.StatusNoContent) } })) defer docker.Close() handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, browserConfig{}) request := httptest.NewRequest(http.MethodDelete, "/v1/browsers/foreign", nil) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, request) if response.Code != http.StatusForbidden || deleted { t.Fatalf("expected unmanaged container to be rejected, status=%d deleted=%v", response.Code, deleted) } } func TestEnsureInternalNetwork(t *testing.T) { var created struct { Name string `json:"Name"` Driver string `json:"Driver"` Internal bool `json:"Internal"` Options map[string]string `json:"Options"` Labels map[string]string `json:"Labels"` } docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { switch request.Method { case http.MethodGet: response.WriteHeader(http.StatusNotFound) case http.MethodPost: if err := json.NewDecoder(request.Body).Decode(&created); err != nil { t.Fatal(err) } response.WriteHeader(http.StatusCreated) } })) defer docker.Close() client := dockerClient{baseURL: docker.URL, client: docker.Client()} if err := client.ensureInternalNetwork("creatorhub_browser"); err != nil { t.Fatal(err) } if created.Name != "creatorhub_browser" || created.Driver != "bridge" || !created.Internal || created.Options[bridgeICCOption] != "false" || len(created.Options) != 1 || created.Labels[managedLabel] != "true" || created.Labels[networkRoleLabel] != browserNetworkRole { t.Fatalf("network is not isolated: %#v", created) } } func TestEnsureInternalNetworkRejectsUnsafeExistingNetwork(t *testing.T) { valid := map[string]any{ "Name": "creatorhub_browser", "Driver": "bridge", "Internal": true, "Attachable": false, "Ingress": false, "Options": map[string]string{bridgeICCOption: "false"}, "Labels": map[string]string{ managedLabel: "true", networkRoleLabel: browserNetworkRole, }, } validDocker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(response).Encode(valid) })) defer validDocker.Close() client := dockerClient{baseURL: validDocker.URL, client: validDocker.Client()} if err := client.ensureInternalNetwork("creatorhub_browser"); err != nil { t.Fatalf("expected owned isolated network to be accepted: %v", err) } tests := map[string]func(map[string]any){ "wrong name": func(network map[string]any) { network["Name"] = "other" }, "wrong driver": func(network map[string]any) { network["Driver"] = "overlay" }, "external": func(network map[string]any) { network["Internal"] = false }, "attachable": func(network map[string]any) { network["Attachable"] = true }, "ingress": func(network map[string]any) { network["Ingress"] = true }, "missing ownership": func(network map[string]any) { network["Labels"] = map[string]string{networkRoleLabel: browserNetworkRole} }, "wrong role": func(network map[string]any) { network["Labels"] = map[string]string{managedLabel: "true", networkRoleLabel: "control"} }, "missing ICC": func(network map[string]any) { network["Options"] = map[string]string{} }, "enabled ICC": func(network map[string]any) { network["Options"] = map[string]string{bridgeICCOption: "true"} }, "extra option": func(network map[string]any) { network["Options"] = map[string]string{bridgeICCOption: "false", "unexpected": "value"} }, } for name, mutate := range tests { t.Run(name, func(t *testing.T) { network := make(map[string]any, len(valid)) for key, value := range valid { network[key] = value } mutate(network) docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(response).Encode(network) })) defer docker.Close() client := dockerClient{baseURL: docker.URL, client: docker.Client()} if err := client.ensureInternalNetwork("creatorhub_browser"); err == nil { t.Fatal("expected unsafe existing network to be rejected") } }) } } func TestEnsureInternalNetworkRejectsControlNetwork(t *testing.T) { requested := false docker := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requested = true })) defer docker.Close() client := dockerClient{baseURL: docker.URL, client: docker.Client()} if err := client.ensureInternalNetwork(controlNetworkName); err == nil || requested { t.Fatalf("expected control network to be rejected before Docker request, requested=%v err=%v", requested, err) } } func TestLoadConfigRejectsControlNetwork(t *testing.T) { t.Setenv("BROWSER_NETWORK", controlNetworkName) if _, err := loadConfig(); err == nil { t.Fatal("expected control network configuration to be rejected") } } func TestDockerIntegrationBlocksCrossContainerTraffic(t *testing.T) { if os.Getenv("CREATORHUB_DOCKER_INTEGRATION") != "1" { t.Skip("set CREATORHUB_DOCKER_INTEGRATION=1 to run") } image := "alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce" networkName := fmt.Sprintf("creatorhub_test_%d", time.Now().UnixNano()) serverName := networkName + "_server" runDocker := func(args ...string) ([]byte, error) { return exec.Command("docker", args...).CombinedOutput() } if output, err := runDocker("pull", image); err != nil { t.Fatalf("pull integration image: %v: %s", err, output) } t.Cleanup(func() { if output, err := runDocker("network", "rm", networkName); err != nil { t.Logf("remove test network: %v: %s", err, output) } }) t.Cleanup(func() { if output, err := runDocker("rm", "-f", serverName); err != nil { t.Logf("remove test container: %v: %s", err, output) } }) transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") }} defer transport.CloseIdleConnections() client := dockerClient{baseURL: "http://docker/v1.43", client: &http.Client{Transport: transport}} if err := client.ensureInternalNetwork(networkName); err != nil { t.Fatal(err) } if output, err := runDocker("run", "-d", "--name", serverName, "--network", networkName, "--network-alias", "peer", image, "nc", "-lk", "-p", "8080", "-e", "/bin/echo"); err != nil { t.Fatalf("start peer server: %v: %s", err, output) } if output, err := runDocker("exec", serverName, "nc", "-z", "-w", "2", "127.0.0.1", "8080"); err != nil { t.Fatalf("peer server is not ready: %v: %s", err, output) } if output, err := runDocker("run", "--rm", "--network", networkName, image, "nc", "-z", "-w", "2", "peer", "8080"); err == nil { t.Fatalf("cross-container request unexpectedly succeeded: %s", output) } }