641 lines
29 KiB
Go
641 lines
29 KiB
Go
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")
|
|
}
|
|
}
|