Files
creator-hub/cmd/docker-gateway/main_test.go
T

352 lines
14 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"testing"
"time"
"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())
}
}
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)
}
}