package main import ( "bytes" "encoding/json" "io" "net" "net/http" "net/http/httptest" "os" "strconv" "strings" "testing" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/adaptor" ) const testToken = "unit-test-gateway-token" func authed(method, target string, body io.Reader) *http.Request { request := httptest.NewRequest(method, target, body) request.Header.Set("Authorization", "Bearer "+testToken) return request } func testDocker(handler http.HandlerFunc) (dockerClient, *httptest.Server) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if strings.HasPrefix(request.URL.Path, "/networks/creatorhub_browser-") { if request.Method != http.MethodGet { if request.Method == http.MethodDelete { response.WriteHeader(http.StatusNoContent) } else { response.WriteHeader(http.StatusOK) } return } alias := strings.TrimPrefix(request.URL.Path, "/networks/creatorhub_browser-") self, _ := os.Hostname() _ = json.NewEncoder(response).Encode(map[string]any{ "Name": "creatorhub_browser-" + alias, "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false, "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: alias}, "Containers": map[string]any{self: map[string]string{"Name": self, "IPv4Address": "127.0.0.1/8"}}, }) return } handler(response, request) })) return dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}, server } func decodeJSONBody(t *testing.T, response *http.Response) map[string]any { t.Helper() var body map[string]any if err := json.NewDecoder(response.Body).Decode(&body); err != nil { t.Fatalf("decode JSON body: %v", err) } return body } const testCreateBody = `{"alias":"account-a","name":"账号甲","image":"registry.example/browser:1.2.3",` + `"cmd":["--fingerprint=1000","--lang=zh-CN","about:blank"],"volume":"creatorhub-profile-account-a",` + `"binding_version":1,"network_exit_id":"exit-1",` + `"network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}` func TestGatewayCreatesNetworkDisabledStoppedRecoveryContainer(t *testing.T) { created := false docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"): _, _ = response.Write([]byte(`{}`)) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"): var payload map[string]any _ = json.NewDecoder(request.Body).Decode(&payload) host := payload["HostConfig"].(map[string]any) labels := payload["Labels"].(map[string]any) encoded, _ := json.Marshal(payload["Cmd"]) if host["NetworkMode"] != "none" || labels[networkExitLabel] != "" || strings.Contains(string(encoded), "proxy") { t.Fatalf("unsafe stopped recovery payload: %#v", payload) } created = true response.WriteHeader(http.StatusCreated) _, _ = response.Write([]byte(`{"Id":"stopped-container"}`)) default: t.Fatalf("stopped recovery unexpectedly called Docker %s %s", request.Method, request.URL.String()) } }) defer server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) body := `{"alias":"account-a","name":"账号甲","image":"registry.example/browser:1.2.3",` + `"cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-account-a",` + `"binding_version":1,"network_exit_id":"","network_exit":{},"stopped":true}` response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body))) if response.Code != http.StatusCreated || !created { t.Fatalf("stopped recovery create failed: status=%d body=%s", response.Code, response.Body.String()) } } func TestGatewayCreatesConstrainedBrowserWithPlatformSpec(t *testing.T) { var created map[string]any docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"): response.WriteHeader(http.StatusOK) _, _ = response.Write([]byte(`{}`)) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/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 && strings.HasSuffix(request.URL.Path, "/containers/container-id/start"): response.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } }) defer server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody))) 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 must run the platform-specified 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"]) } cmd := created["Cmd"].([]any) if len(cmd) != 5 || cmd[0] != "--fingerprint=1000" || !strings.HasPrefix(cmd[2].(string), "--proxy-server=http://docker-gateway:") || cmd[3] != "--disable-non-proxied-udp" || cmd[4] != "about:blank" { t.Fatalf("cmd must be passed through verbatim: %#v", created["Cmd"]) } host := created["HostConfig"].(map[string]any) if host["NetworkMode"] != "creatorhub_browser-account-a" || 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) } mount := host["Mounts"].([]any)[0].(map[string]any) if mount["Source"] != "creatorhub-profile-account-a" || mount["Target"] != "/data" { t.Fatalf("profile volume must come from the request: %#v", mount) } labels := created["Labels"].(map[string]any) if labels[managedLabel] != "true" || labels[idLabel] != "account-a" || labels[nameLabel] != "账号甲" { t.Fatalf("missing ownership labels: %#v", labels) } } func TestGatewayDockerInspectContainsNoProxyCredentials(t *testing.T) { var created map[string]any docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"): response.WriteHeader(http.StatusOK) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"): 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 && strings.HasSuffix(request.URL.Path, "/containers/container-id/start"): response.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } }) defer server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) body := strings.Replace(testCreateBody, `"protocol":"socks5","host":"proxy.example","port":1080`, `"protocol":"socks5","host":"proxy.example","port":1080,"username":"operator","password":"ephemeral"`, 1) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body))) if response.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String()) } inspect, _ := json.Marshal(created) for _, secret := range []string{"operator", "ephemeral", "operator:ephemeral@", "proxy.example"} { if bytes.Contains(inspect, []byte(secret)) { t.Fatalf("Docker inspect leaked proxy credential %q: %s", secret, inspect) } } if !bytes.Contains(inspect, []byte("--proxy-server=http://docker-gateway:")) { t.Fatalf("Docker inspect is missing the secret-free proxy configuration: %s", inspect) } } func TestGatewayPullsMissingImageOnCreate(t *testing.T) { tests := []struct { name string ref string fromImage string tag string }{{ name: "tagged ref splits repository and tag", ref: "registry.example/browser:2.0.0", fromImage: "registry.example/browser", tag: "2.0.0", }, { name: "digest ref is pulled as a whole", ref: "registry.example/browser@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7", fromImage: "registry.example/browser@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7", tag: "", }} for _, test := range tests { t.Run(test.name, func(t *testing.T) { pulled := false docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"): response.WriteHeader(http.StatusNotFound) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/images/create"): pulled = true if request.URL.Query().Get("fromImage") != test.fromImage || request.URL.Query().Get("tag") != test.tag { t.Fatalf("unexpected pull query %s", request.URL.RawQuery) } _, _ = response.Write([]byte(`{"status":"Download complete"}`)) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"): response.WriteHeader(http.StatusCreated) _, _ = response.Write([]byte(`{"Id":"container-id"}`)) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/start"): response.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } }) defer server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) body := `{"alias":"account-a","name":"账号甲","image":"` + test.ref + `","cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-account-a",` + `"binding_version":1,"network_exit_id":"exit-1",` + `"network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}` response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body))) if response.Code != http.StatusCreated || !pulled { t.Fatalf("expected pull-then-create, status=%d pulled=%v body=%s", response.Code, pulled, response.Body.String()) } }) } } func TestGatewayRejectsCreateWithoutValidToken(t *testing.T) { docker, server := testDocker(func(http.ResponseWriter, *http.Request) { t.Fatal("no Docker request is expected for an unauthorized call") }) defer server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) for name, header := range map[string]string{ "missing": "", "malformed": testToken, "wrong": "Bearer not-the-token", } { request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody)) if header != "" { request.Header.Set("Authorization", header) } response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("%s token: expected 401, got %d: %s", name, response.Code, response.Body.String()) } } response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if response.Code != http.StatusNoContent { t.Fatalf("healthz must stay unauthenticated, got %d", response.Code) } } func TestGatewayRejectsInvalidCreateRequest(t *testing.T) { tests := map[string]string{ "unknown field": `{"alias":"account-a","seed":1}`, "invalid alias": `{"alias":"AccountA","name":"甲","image":"reg/img:1","cmd":["--fingerprint=1"],"volume":"creatorhub-profile-account-a"}`, "invalid image": `{"alias":"account-a","name":"甲","image":"","cmd":["--fingerprint=1"],"volume":"creatorhub-profile-account-a"}`, "empty cmd": `{"alias":"account-a","name":"甲","image":"reg/img:1","cmd":[],"volume":"creatorhub-profile-account-a"}`, "invalid volume": `{"alias":"account-a","name":"甲","image":"reg/img:1","cmd":["--fingerprint=1"],"volume":"bad volume!"}`, "proxy override": `{"alias":"account-a","name":"甲","image":"reg/img:1","cmd":["--fingerprint=1","--proxy-server=http://direct:8080","about:blank"],"volume":"creatorhub-profile-account-a","network_exit":{"protocol":"socks5","host":"proxy","port":1080}}`, } for name, body := range tests { t.Run(name, func(t *testing.T) { handler := newGateway(dockerClient{}, "creatorhub_browser", testToken) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body))) if response.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", response.Code, response.Body.String()) } }) } } func TestGatewayRejectsOversizedCreateRequest(t *testing.T) { handler := newGateway(dockerClient{}, "creatorhub_browser", testToken) request := authed(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, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"): response.WriteHeader(http.StatusOK) _, _ = response.Write([]byte(`{}`)) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"): response.WriteHeader(http.StatusCreated) _, _ = response.Write([]byte(`{"Id":""}`)) case request.Method == http.MethodDelete && strings.Contains(request.URL.Path, 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 server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody))) 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, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"): response.WriteHeader(http.StatusOK) _, _ = response.Write([]byte(`{}`)) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"): response.WriteHeader(http.StatusCreated) _, _ = response.Write([]byte(`{"Id":"failed-id"}`)) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/failed-id/start"): http.Error(response, "start failed", http.StatusInternalServerError) case request.Method == http.MethodDelete && strings.Contains(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 server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody))) 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 TestGatewayDoesNotEchoProxyCredentialsFromDockerErrors(t *testing.T) { docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { if request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/") { response.WriteHeader(http.StatusOK) return } if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create") { response.WriteHeader(http.StatusInternalServerError) _, _ = response.Write([]byte(`invalid cmd --proxy-server=http://operator:ephemeral@proxy.example:8080`)) return } t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.Path) }) defer server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) body := strings.Replace(testCreateBody, `"protocol":"socks5","host":"proxy.example","port":1080`, `"protocol":"http","host":"proxy.example","port":8080,"username":"operator","password":"ephemeral"`, 1) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body))) if response.Code != http.StatusBadGateway || strings.Contains(response.Body.String(), "operator") || strings.Contains(response.Body.String(), "ephemeral") || strings.Contains(response.Body.String(), "proxy.example") { t.Fatalf("gateway leaked proxy material: status=%d body=%s", response.Code, response.Body.String()) } } func TestGatewayListsBrowsers(t *testing.T) { docker, server := testDocker(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":{` + `"` + idLabel + `":"account-a","` + nameLabel + `":"账号甲"}}]`)) }) defer server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(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].Alias != "account-a" || browsers[0].Name != "账号甲" || browsers[0].Endpoint != "http://creatorhub-browser-account-a:9222" { t.Fatalf("unexpected list response status=%d body=%s", response.Code, response.Body.String()) } } func TestGatewayRestartRestoresExistingProxyListener(t *testing.T) { reserved, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { t.Fatal(err) } port := reserved.Addr().(*net.TCPAddr).Port _ = reserved.Close() labels := map[string]string{ managedLabel: "true", idLabel: "account-a", nameLabel: "账号甲", bindingVersionLabel: "3", networkExitLabel: "exit-1", proxyPortLabel: strconv.Itoa(port), } docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/containers/creatorhub-browser-account-a/json"): _ = json.NewEncoder(response).Encode(map[string]any{"Config": map[string]any{"Labels": labels}}) case request.Method == http.MethodGet && request.URL.Path == "/containers/json": _ = json.NewEncoder(response).Encode([]map[string]any{{"Id": "container-id", "State": "running", "Status": "Up", "Labels": labels}}) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } }) defer server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) recovery := `{"binding_version":3,"network_exit_id":"exit-1","network_exit":{"protocol":"http","host":"127.0.0.1","port":1}}` response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers/account-a/proxy", strings.NewReader(recovery))) if response.Code != http.StatusNoContent { t.Fatalf("proxy recovery failed: %d %s", response.Code, response.Body.String()) } response = httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(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].ProxyReady { t.Fatalf("restarted gateway did not report restored proxy: %d %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, server := testDocker(func(response http.ResponseWriter, request *http.Request) { if request.Method == http.MethodGet { _, _ = response.Write([]byte(`{"Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a"}}}`)) return } if request.URL.Path != test.dockerPath { t.Fatalf("unexpected Docker path %s", request.URL.String()) } response.WriteHeader(http.StatusNoContent) }) defer server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(test.method, test.path, nil)) if response.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String()) } }) } } func TestGatewayDeleteDistinguishesContainerRemovalFromNetworkCleanup(t *testing.T) { containerExists, cleanupFails, containerDeletes := true, true, 0 server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"): if !containerExists { response.WriteHeader(http.StatusNotFound) return } _, _ = response.Write([]byte(`{"Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a"}}}`)) case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/containers/"): containerExists = false containerDeletes++ response.WriteHeader(http.StatusNoContent) case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/networks/"): _ = json.NewEncoder(response).Encode(map[string]any{ "Name": "creatorhub_browser-account-a", "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a"}, }) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/disconnect"): if cleanupFails { response.WriteHeader(http.StatusInternalServerError) return } response.WriteHeader(http.StatusOK) case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/networks/"): response.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) } })) defer server.Close() handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}, "creatorhub_browser", testToken, "gateway-self") response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", nil)) if response.Code != http.StatusAccepted || containerExists || containerDeletes != 1 { t.Fatalf("expected definite container removal with pending cleanup, status=%d exists=%v deletes=%d body=%s", response.Code, containerExists, containerDeletes, response.Body.String()) } cleanupFails = false response = httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", nil)) if response.Code != http.StatusNoContent || containerDeletes != 1 { t.Fatalf("idempotent cleanup retry failed: status=%d deletes=%d body=%s", response.Code, containerDeletes, response.Body.String()) } } func TestGatewayMapsDockerServiceFailureToBadGateway(t *testing.T) { docker, server := testDocker(func(response http.ResponseWriter, _ *http.Request) { http.Error(response, "daemon unavailable", http.StatusInternalServerError) }) defer server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) response := httptest.NewRecorder() adaptor.FiberApp(handler).ServeHTTP(response, authed(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, server := testDocker(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 server.Close() handler := newGateway(docker, "creatorhub_browser", testToken) request := authed(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 TestEnsureTenantNetworkConnectsGatewayOnlyToRuntimeNetwork(t *testing.T) { created, connected := false, false server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodGet && !created: response.WriteHeader(http.StatusNotFound) case request.Method == http.MethodPost && request.URL.Path == "/networks/create": var body map[string]any _ = json.NewDecoder(request.Body).Decode(&body) labels := body["Labels"].(map[string]any) if body["Name"] != "creatorhub_browser-account-a" || labels[idLabel] != "account-a" { t.Fatalf("unexpected isolated network create: %#v", body) } created = true response.WriteHeader(http.StatusCreated) case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/connect"): connected = true response.WriteHeader(http.StatusOK) case request.Method == http.MethodGet: _ = json.NewEncoder(response).Encode(map[string]any{ "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false, "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a"}, "Containers": map[string]any{"gateway-id": map[string]string{"Name": "gateway-id", "IPv4Address": "127.0.0.3/8"}}, }) default: t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.Path) } })) defer server.Close() docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()} name, bindHost, err := docker.ensureTenantNetwork("creatorhub_browser", "account-a", "gateway-id") if err != nil || !created || !connected || name != "creatorhub_browser-account-a" || bindHost != "127.0.0.3" { t.Fatalf("isolated network was not created and connected: name=%q host=%q created=%v connected=%v err=%v", name, bindHost, created, connected, err) } } func TestLoadConfigRequiresGatewayToken(t *testing.T) { t.Setenv("GATEWAY_TOKEN", "short") if _, err := loadConfig(); err == nil { t.Fatal("expected a short gateway token to be rejected") } } func TestLoadConfigRejectsControlNetwork(t *testing.T) { t.Setenv("GATEWAY_TOKEN", testToken) t.Setenv("BROWSER_NETWORK", controlNetworkName) if _, err := loadConfig(); err == nil { t.Fatal("expected control network configuration to be rejected") } }