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

482 lines
20 KiB
Go

package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"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(handler)
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"}`
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) != 3 || cmd[0] != "--fingerprint=1000" || cmd[2] != "about:blank" {
t.Fatalf("cmd must be passed through verbatim: %#v", created["Cmd"])
}
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)
}
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 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"}`
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!"}`,
}
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 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 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 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 TestEnsureBrowserNetwork(t *testing.T) {
var created struct {
Name string `json:"Name"`
Driver string `json:"Driver"`
Labels map[string]string `json:"Labels"`
}
docker, server := testDocker(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 server.Close()
if err := docker.ensureBrowserNetwork("creatorhub_browser"); err != nil {
t.Fatal(err)
}
if created.Name != "creatorhub_browser" || created.Driver != "bridge" ||
created.Labels[managedLabel] != "true" || created.Labels[networkRoleLabel] != browserNetworkRole {
t.Fatalf("network is not a CreatorHub bridge: %#v", created)
}
}
func TestEnsureBrowserNetworkRejectsUnsafeExistingNetwork(t *testing.T) {
valid := map[string]any{
"Name": "creatorhub_browser",
"Driver": "bridge",
"Internal": false,
"Attachable": false,
"Ingress": false,
"Labels": map[string]string{
managedLabel: "true",
networkRoleLabel: browserNetworkRole,
},
}
validDocker, validServer := testDocker(func(response http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(response).Encode(valid)
})
defer validServer.Close()
if err := validDocker.ensureBrowserNetwork("creatorhub_browser"); err != nil {
t.Fatalf("expected owned bridge 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" },
"internal": func(network map[string]any) { network["Internal"] = true },
"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"}
},
}
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, server := testDocker(func(response http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(response).Encode(network)
})
defer server.Close()
if err := docker.ensureBrowserNetwork("creatorhub_browser"); err == nil {
t.Fatal("expected unsafe existing network to be rejected")
}
})
}
}
func TestEnsureBrowserNetworkRejectsControlNetwork(t *testing.T) {
requested := false
docker, server := testDocker(func(http.ResponseWriter, *http.Request) { requested = true })
defer server.Close()
if err := docker.ensureBrowserNetwork(controlNetworkName); err == nil || requested {
t.Fatalf("expected control network to be rejected before Docker request, requested=%v err=%v", requested, 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")
}
}