673 lines
24 KiB
Go
673 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/gofiber/fiber/v3/middleware/adaptor"
|
|
)
|
|
|
|
// memoryStore 是 hubStore 的内存桩,记录写入以便断言编排副作用。
|
|
type memoryStore struct {
|
|
mu sync.Mutex
|
|
gateways map[string]hub.Gateway
|
|
images map[string]hub.Image
|
|
envs map[string]hub.Env
|
|
deleted []string
|
|
upgraded map[string]string
|
|
upgradeErr error
|
|
gatewayFn func(name string) (hub.Gateway, error)
|
|
}
|
|
|
|
func newMemoryStore() *memoryStore {
|
|
return &memoryStore{
|
|
gateways: map[string]hub.Gateway{},
|
|
images: map[string]hub.Image{},
|
|
envs: map[string]hub.Env{},
|
|
upgraded: map[string]string{},
|
|
}
|
|
}
|
|
|
|
func (s *memoryStore) CreateGateway(_ context.Context, _, _, _ string) (hub.Gateway, error) {
|
|
return hub.Gateway{}, nil
|
|
}
|
|
func (s *memoryStore) ListGateways(context.Context) ([]hub.Gateway, error) { return nil, nil }
|
|
func (s *memoryStore) GetGateway(_ context.Context, name string) (hub.Gateway, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.gatewayFn != nil {
|
|
return s.gatewayFn(name)
|
|
}
|
|
gateway, ok := s.gateways[name]
|
|
if !ok {
|
|
return hub.Gateway{}, hub.ErrNotFound
|
|
}
|
|
return gateway, nil
|
|
}
|
|
func (s *memoryStore) DeleteGateway(context.Context, string) error { return nil }
|
|
func (s *memoryStore) CreateImage(_ context.Context, image hub.Image) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.images[image.Version] = image
|
|
return nil
|
|
}
|
|
func (s *memoryStore) UpdateImage(_ context.Context, image hub.Image) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.images[image.Version] = image
|
|
return nil
|
|
}
|
|
func (s *memoryStore) ListImages(context.Context, bool) ([]hub.Image, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
images := make([]hub.Image, 0, len(s.images))
|
|
for _, image := range s.images {
|
|
images = append(images, image)
|
|
}
|
|
return images, nil
|
|
}
|
|
func (s *memoryStore) DeleteImage(context.Context, string) error { return nil }
|
|
func (s *memoryStore) ImageRef(_ context.Context, version string) (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
image, ok := s.images[version]
|
|
if !ok || !image.Enabled {
|
|
return "", hub.ErrNotFound
|
|
}
|
|
return image.ImageRef, nil
|
|
}
|
|
func (s *memoryStore) CreateEnv(_ context.Context, env hub.Env) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, exists := s.envs[env.Alias]; exists {
|
|
return hub.ErrConflict
|
|
}
|
|
if image, exists := s.images[env.ImageVersion]; !exists || !image.Enabled {
|
|
return hub.ErrNotFound
|
|
}
|
|
s.envs[env.Alias] = env
|
|
return nil
|
|
}
|
|
func (s *memoryStore) ListEnvs(context.Context) ([]hub.Env, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
envs := make([]hub.Env, 0, len(s.envs))
|
|
for _, env := range s.envs {
|
|
envs = append(envs, env)
|
|
}
|
|
return envs, nil
|
|
}
|
|
func (s *memoryStore) GetEnv(_ context.Context, alias string) (hub.Env, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
env, ok := s.envs[alias]
|
|
if !ok {
|
|
return hub.Env{}, hub.ErrNotFound
|
|
}
|
|
return env, nil
|
|
}
|
|
func (s *memoryStore) UpgradeEnv(_ context.Context, alias, version string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.upgradeErr != nil {
|
|
return s.upgradeErr
|
|
}
|
|
if _, ok := s.envs[alias]; !ok {
|
|
return hub.ErrNotFound
|
|
}
|
|
if image, exists := s.images[version]; !exists || !image.Enabled {
|
|
return hub.ErrNotFound
|
|
}
|
|
s.upgraded[alias] = version
|
|
env := s.envs[alias]
|
|
env.ImageVersion = version
|
|
s.envs[alias] = env
|
|
return nil
|
|
}
|
|
func (s *memoryStore) DeleteEnv(_ context.Context, alias string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.deleted = append(s.deleted, alias)
|
|
delete(s.envs, alias)
|
|
return nil
|
|
}
|
|
|
|
type recordedRequest struct {
|
|
method string
|
|
path string
|
|
body map[string]any
|
|
}
|
|
|
|
// fakeGateway 模拟 docker-gateway:按路由表应答并记录请求。
|
|
type fakeGateway struct {
|
|
mu sync.Mutex
|
|
createOnce sync.Once
|
|
token string
|
|
requests []recordedRequest
|
|
containers []containerStatus
|
|
failCreate int // 前 N 次 create 返回失败
|
|
createStarted chan struct{}
|
|
releaseCreate <-chan struct{}
|
|
}
|
|
|
|
func (g *fakeGateway) handler(t *testing.T) http.Handler {
|
|
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if request.Header.Get("Authorization") != "Bearer "+g.token {
|
|
response.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = response.Write([]byte(`{"error":"gateway token rejected"}`))
|
|
return
|
|
}
|
|
var body map[string]any
|
|
if request.Body != nil {
|
|
raw, _ := io.ReadAll(request.Body)
|
|
if len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &body)
|
|
}
|
|
}
|
|
g.mu.Lock()
|
|
g.requests = append(g.requests, recordedRequest{method: request.Method, path: request.URL.Path, body: body})
|
|
g.mu.Unlock()
|
|
|
|
switch {
|
|
case request.Method == http.MethodPost && request.URL.Path == "/v1/browsers":
|
|
g.mu.Lock()
|
|
if g.failCreate > 0 {
|
|
g.failCreate--
|
|
g.mu.Unlock()
|
|
response.WriteHeader(http.StatusConflict)
|
|
_, _ = response.Write([]byte(`{"error":"alias already in use"}`))
|
|
return
|
|
}
|
|
g.mu.Unlock()
|
|
if g.createStarted != nil {
|
|
g.createOnce.Do(func() { close(g.createStarted) })
|
|
}
|
|
if g.releaseCreate != nil {
|
|
<-g.releaseCreate
|
|
}
|
|
response.WriteHeader(http.StatusCreated)
|
|
_, _ = response.Write([]byte(`{"id":"container-id","alias":"account-a"}`))
|
|
case request.Method == http.MethodGet && request.URL.Path == "/v1/browsers":
|
|
_ = json.NewEncoder(response).Encode(g.containers)
|
|
case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/v1/browsers/"):
|
|
response.WriteHeader(http.StatusNoContent)
|
|
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/start"),
|
|
request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/stop"):
|
|
response.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
t.Fatalf("unexpected gateway request %s %s", request.Method, request.URL.Path)
|
|
}
|
|
})
|
|
}
|
|
|
|
func (g *fakeGateway) recorded() []recordedRequest {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return append([]recordedRequest{}, g.requests...)
|
|
}
|
|
|
|
func newTestApp(t *testing.T, store *memoryStore, gateway *fakeGateway) *fiber.App {
|
|
t.Helper()
|
|
server := httptest.NewServer(gateway.handler(t))
|
|
t.Cleanup(server.Close)
|
|
if store.gateways == nil {
|
|
store.gateways = map[string]hub.Gateway{}
|
|
}
|
|
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
|
|
app := fiber.New()
|
|
registerHub(app, store)
|
|
return app
|
|
}
|
|
|
|
func do(app *fiber.App, method, path, body string) *httptest.ResponseRecorder {
|
|
response := httptest.NewRecorder()
|
|
var reader io.Reader
|
|
if body != "" {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
adaptor.FiberApp(app).ServeHTTP(response, httptest.NewRequest(method, path, reader))
|
|
return response
|
|
}
|
|
|
|
const createEnvBody = `{"alias":"account-a","name":"店铺一号","gateway":"gw-1","image_version":"148.0.7778.215",` +
|
|
`"fingerprint":{"seed":2024,"platform":"windows","timezone":"Asia/Shanghai"}}`
|
|
|
|
func TestCreateBrowserOrchestratesGateway(t *testing.T) {
|
|
store := newMemoryStore()
|
|
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser@sha256:abc", Enabled: true})
|
|
gateway := &fakeGateway{token: "unit-test-gateway-token"}
|
|
app := newTestApp(t, store, gateway)
|
|
|
|
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
|
|
if response.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
if _, kept := store.envs["account-a"]; !kept {
|
|
t.Fatal("env must be persisted after successful gateway create")
|
|
}
|
|
requests := gateway.recorded()
|
|
if len(requests) != 1 || requests[0].method != http.MethodPost || requests[0].path != "/v1/browsers" {
|
|
t.Fatalf("unexpected gateway calls: %#v", requests)
|
|
}
|
|
payload := requests[0].body
|
|
if payload["alias"] != "account-a" || payload["name"] != "店铺一号" ||
|
|
payload["image"] != "registry.example/browser@sha256:abc" ||
|
|
payload["volume"] != "creatorhub-profile-account-a" {
|
|
t.Fatalf("platform must fully specify the gateway payload: %#v", payload)
|
|
}
|
|
cmd := payload["cmd"].([]any)
|
|
if len(cmd) != 4 || cmd[0] != "--fingerprint=2024" || cmd[1] != "--fingerprint-platform=windows" ||
|
|
cmd[2] != "--timezone=Asia/Shanghai" || cmd[3] != "about:blank" {
|
|
t.Fatalf("cmd must carry fingerprint args plus start url: %#v", cmd)
|
|
}
|
|
}
|
|
|
|
func TestCreateBrowserRollsBackWhenGatewayRejects(t *testing.T) {
|
|
store := newMemoryStore()
|
|
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true})
|
|
gateway := &fakeGateway{token: "unit-test-gateway-token", failCreate: 1}
|
|
app := newTestApp(t, store, gateway)
|
|
|
|
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
|
|
if response.Code != http.StatusConflict {
|
|
t.Fatalf("expected gateway conflict to pass through as 409, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
if len(store.deleted) != 1 || store.deleted[0] != "account-a" {
|
|
t.Fatalf("env must be rolled back after gateway rejection, deleted=%v", store.deleted)
|
|
}
|
|
if _, kept := store.envs["account-a"]; kept {
|
|
t.Fatal("rolled-back env must not remain in the store")
|
|
}
|
|
}
|
|
|
|
func TestCreateBrowserReconcilesDisconnectedGateway(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
reconcile bool
|
|
wantStatus int
|
|
}{
|
|
{name: "completed create", reconcile: true, wantStatus: http.StatusCreated},
|
|
{name: "unknown result", wantStatus: http.StatusBadGateway},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var created bool
|
|
var mu sync.Mutex
|
|
gatewayServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if request.Header.Get("Authorization") != "Bearer unit-test-gateway-token" {
|
|
response.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if request.Method == http.MethodGet && test.reconcile {
|
|
mu.Lock()
|
|
exists := created
|
|
mu.Unlock()
|
|
if exists {
|
|
_ = json.NewEncoder(response).Encode([]containerStatus{{ID: "container-id", Alias: "account-a"}})
|
|
return
|
|
}
|
|
_ = json.NewEncoder(response).Encode([]containerStatus{})
|
|
return
|
|
}
|
|
if request.Method == http.MethodPost {
|
|
mu.Lock()
|
|
created = true
|
|
mu.Unlock()
|
|
}
|
|
hijacker, ok := response.(http.Hijacker)
|
|
if !ok {
|
|
t.Error("test server does not support hijacking")
|
|
return
|
|
}
|
|
connection, _, err := hijacker.Hijack()
|
|
if err != nil {
|
|
t.Errorf("hijack gateway response: %v", err)
|
|
return
|
|
}
|
|
_ = connection.Close()
|
|
}))
|
|
defer gatewayServer.Close()
|
|
|
|
store := newMemoryStore()
|
|
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true})
|
|
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"}
|
|
app := fiber.New()
|
|
registerHub(app, store)
|
|
|
|
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
|
|
if response.Code != test.wantStatus {
|
|
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
if _, err := store.GetEnv(context.Background(), "account-a"); err != nil {
|
|
t.Fatalf("unknown gateway result must retain the environment for reconciliation: %v", err)
|
|
}
|
|
if len(store.deleted) != 0 {
|
|
t.Fatalf("unknown result must not roll back the environment: %v", store.deleted)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateBrowserReconcilesGatewayBadGateway(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
missingReads int
|
|
}{
|
|
{name: "Docker create disconnect returned 502"},
|
|
{name: "container becomes visible after query window", missingReads: 2},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var mu sync.Mutex
|
|
listCalls := 0
|
|
gatewayServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if request.Header.Get("Authorization") != "Bearer unit-test-gateway-token" {
|
|
response.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if request.Method == http.MethodPost {
|
|
response.WriteHeader(http.StatusBadGateway)
|
|
_, _ = response.Write([]byte(`{"error":"create container: context deadline exceeded"}`))
|
|
return
|
|
}
|
|
mu.Lock()
|
|
listCalls++
|
|
missing := listCalls <= test.missingReads
|
|
mu.Unlock()
|
|
if missing {
|
|
_ = json.NewEncoder(response).Encode([]containerStatus{})
|
|
return
|
|
}
|
|
_ = json.NewEncoder(response).Encode([]containerStatus{{ID: "container-id", Alias: "account-a"}})
|
|
}))
|
|
defer gatewayServer.Close()
|
|
|
|
store := newMemoryStore()
|
|
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true})
|
|
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"}
|
|
app := fiber.New()
|
|
registerHub(app, store)
|
|
|
|
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
|
|
if response.Code != http.StatusCreated {
|
|
t.Fatalf("expected reconciled create, status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
if _, err := store.GetEnv(context.Background(), "account-a"); err != nil || len(store.deleted) != 0 {
|
|
t.Fatalf("reconciled create must retain DB state: err=%v deleted=%v", err, store.deleted)
|
|
}
|
|
mu.Lock()
|
|
gotCalls := listCalls
|
|
mu.Unlock()
|
|
if gotCalls != test.missingReads+1 {
|
|
t.Fatalf("expected %d reconciliation reads, got %d", test.missingReads+1, gotCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateBrowserRejectsInvalidFingerprintBeforeSideEffects(t *testing.T) {
|
|
store := newMemoryStore()
|
|
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true})
|
|
gateway := &fakeGateway{token: "unit-test-gateway-token"}
|
|
app := newTestApp(t, store, gateway)
|
|
|
|
response := do(app, http.MethodPost, "/api/browsers",
|
|
`{"alias":"account-a","name":"甲","gateway":"gw-1","image_version":"148.0.7778.215","fingerprint":{"seed":0}}`)
|
|
if response.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid fingerprint, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
if len(gateway.recorded()) != 0 || len(store.envs) != 0 {
|
|
t.Fatal("invalid input must not reach the gateway or the store")
|
|
}
|
|
}
|
|
|
|
func TestListBrowsersMergesLiveGatewayState(t *testing.T) {
|
|
store := newMemoryStore()
|
|
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148"}
|
|
store.envs["account-b"] = hub.Env{Alias: "account-b", Name: "店铺二号", Gateway: "gw-1", ImageVersion: "148"}
|
|
gateway := &fakeGateway{
|
|
token: "unit-test-gateway-token",
|
|
containers: []containerStatus{
|
|
{ID: "id-1", Alias: "account-a", State: "running", Status: "Up", Endpoint: "http://creatorhub-browser-account-a:9222"},
|
|
},
|
|
}
|
|
app := newTestApp(t, store, gateway)
|
|
|
|
response := do(app, http.MethodGet, "/api/browsers", "")
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
var views []envView
|
|
if err := json.NewDecoder(response.Body).Decode(&views); err != nil || len(views) != 2 {
|
|
t.Fatalf("expected two env views, err=%v body=%s", err, response.Body.String())
|
|
}
|
|
byAlias := map[string]envView{}
|
|
for _, view := range views {
|
|
byAlias[view.Alias] = view
|
|
}
|
|
if byAlias["account-a"].State != "running" || byAlias["account-a"].ContainerID != "id-1" {
|
|
t.Fatalf("running container state must be merged: %#v", byAlias["account-a"])
|
|
}
|
|
if byAlias["account-b"].State != "missing" {
|
|
t.Fatalf("env without container must report missing: %#v", byAlias["account-b"])
|
|
}
|
|
}
|
|
|
|
func TestUpgradeBrowserRecreatesWithSameVolumeAndParams(t *testing.T) {
|
|
store := newMemoryStore()
|
|
store.envs["account-a"] = hub.Env{
|
|
Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148",
|
|
Fingerprint: hub.Fingerprint{Seed: 2024, Timezone: "Asia/Shanghai"},
|
|
}
|
|
_ = store.CreateImage(nil, hub.Image{Version: "144.0.7559.132", ImageRef: "registry.example/browser:144", Enabled: true})
|
|
gateway := &fakeGateway{token: "unit-test-gateway-token"}
|
|
app := newTestApp(t, store, gateway)
|
|
|
|
response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"144.0.7559.132"}`)
|
|
if response.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
requests := gateway.recorded()
|
|
if len(requests) != 2 || requests[0].method != http.MethodDelete || requests[0].path != "/v1/browsers/account-a" ||
|
|
requests[1].method != http.MethodPost || requests[1].path != "/v1/browsers" {
|
|
t.Fatalf("upgrade must delete then recreate: %#v", requests)
|
|
}
|
|
payload := requests[1].body
|
|
if payload["image"] != "registry.example/browser:144" || payload["volume"] != "creatorhub-profile-account-a" {
|
|
t.Fatalf("upgrade must reuse the profile volume and switch image: %#v", payload)
|
|
}
|
|
cmd := payload["cmd"].([]any)
|
|
if cmd[0] != "--fingerprint=2024" || cmd[len(cmd)-1] != "about:blank" {
|
|
t.Fatalf("upgrade must reuse stored fingerprint params: %#v", cmd)
|
|
}
|
|
if store.upgraded["account-a"] != "144.0.7559.132" || store.envs["account-a"].ImageVersion != "144.0.7559.132" {
|
|
t.Fatal("image version must be persisted after successful upgrade")
|
|
}
|
|
}
|
|
|
|
func TestUpgradeBrowserRemovesUncommittedContainer(t *testing.T) {
|
|
store := newMemoryStore()
|
|
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 2024}}
|
|
store.upgradeErr = hub.ErrNotFound
|
|
_ = store.CreateImage(nil, hub.Image{Version: "144.0.7559.132", ImageRef: "registry.example/browser:144", Enabled: true})
|
|
gateway := &fakeGateway{token: "unit-test-gateway-token"}
|
|
app := newTestApp(t, store, gateway)
|
|
|
|
response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"144.0.7559.132"}`)
|
|
if response.Code != http.StatusNotFound {
|
|
t.Fatalf("expected persistence failure, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
requests := gateway.recorded()
|
|
if len(requests) != 3 || requests[2].method != http.MethodDelete || requests[2].path != "/v1/browsers/account-a" {
|
|
t.Fatalf("uncommitted upgraded container must be removed: %#v", requests)
|
|
}
|
|
env, err := store.GetEnv(context.Background(), "account-a")
|
|
if err != nil || env.ImageVersion != "148" {
|
|
t.Fatalf("failed upgrade must preserve the stored version: %#v %v", env, err)
|
|
}
|
|
}
|
|
|
|
func TestImageDisableWaitsForUpgradeCommit(t *testing.T) {
|
|
releaseCreate := make(chan struct{})
|
|
gateway := &fakeGateway{
|
|
token: "unit-test-gateway-token",
|
|
createStarted: make(chan struct{}),
|
|
releaseCreate: releaseCreate,
|
|
}
|
|
store := newMemoryStore()
|
|
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 2024}}
|
|
_ = store.CreateImage(nil, hub.Image{Version: "144.0.7559.132", ImageRef: "registry.example/browser:144", Enabled: true})
|
|
gatewayServer := httptest.NewServer(gateway.handler(t))
|
|
defer gatewayServer.Close()
|
|
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: gateway.token}
|
|
disableArrived := make(chan struct{})
|
|
var disableOnce sync.Once
|
|
app := fiber.New()
|
|
app.Use(func(c fiber.Ctx) error {
|
|
if c.Method() == http.MethodPut {
|
|
disableOnce.Do(func() { close(disableArrived) })
|
|
}
|
|
return c.Next()
|
|
})
|
|
registerHub(app, store)
|
|
server := httptest.NewServer(adaptor.FiberApp(app))
|
|
defer server.Close()
|
|
|
|
type result struct {
|
|
status int
|
|
err error
|
|
}
|
|
upgradeDone := make(chan result, 1)
|
|
go func() {
|
|
response, err := server.Client().Post(server.URL+"/api/browsers/account-a/upgrade", "application/json", strings.NewReader(`{"version":"144.0.7559.132"}`))
|
|
if err != nil {
|
|
upgradeDone <- result{err: err}
|
|
return
|
|
}
|
|
defer response.Body.Close()
|
|
upgradeDone <- result{status: response.StatusCode}
|
|
}()
|
|
select {
|
|
case <-gateway.createStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("upgrade did not reach gateway create")
|
|
}
|
|
|
|
disableDone := make(chan result, 1)
|
|
go func() {
|
|
request, err := http.NewRequest(http.MethodPut, server.URL+"/api/browser-images/144.0.7559.132", strings.NewReader(
|
|
`{"image_ref":"registry.example/browser:144","enabled":false}`))
|
|
if err != nil {
|
|
disableDone <- result{err: err}
|
|
return
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
response, err := server.Client().Do(request)
|
|
if err != nil {
|
|
disableDone <- result{err: err}
|
|
return
|
|
}
|
|
defer response.Body.Close()
|
|
disableDone <- result{status: response.StatusCode}
|
|
}()
|
|
<-disableArrived
|
|
select {
|
|
case result := <-disableDone:
|
|
t.Fatalf("disable completed before upgrade commit: %#v", result)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
close(releaseCreate)
|
|
if result := <-upgradeDone; result.err != nil || result.status != http.StatusNoContent {
|
|
t.Fatalf("upgrade failed: %#v", result)
|
|
}
|
|
if result := <-disableDone; result.err != nil || result.status != http.StatusNoContent {
|
|
t.Fatalf("disable failed: %#v", result)
|
|
}
|
|
env, err := store.GetEnv(context.Background(), "account-a")
|
|
if err != nil || env.ImageVersion != "144.0.7559.132" {
|
|
t.Fatalf("upgrade must commit before disable: %#v %v", env, err)
|
|
}
|
|
if _, err := store.ImageRef(context.Background(), "144.0.7559.132"); !errors.Is(err, hub.ErrNotFound) {
|
|
t.Fatalf("disable must apply after upgrade: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBrowserActionRoutesStartStopAndRejectsUnknown(t *testing.T) {
|
|
store := newMemoryStore()
|
|
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
|
|
gateway := &fakeGateway{token: "unit-test-gateway-token"}
|
|
app := newTestApp(t, store, gateway)
|
|
|
|
if response := do(app, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204 for start, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
if response := do(app, http.MethodPost, "/api/browsers/account-a/pause", ""); response.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for unknown action, got %d", response.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteBrowserKeepsEnvAndContainerInSync(t *testing.T) {
|
|
store := newMemoryStore()
|
|
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
|
|
gateway := &fakeGateway{token: "unit-test-gateway-token"}
|
|
app := newTestApp(t, store, gateway)
|
|
|
|
if response := do(app, http.MethodDelete, "/api/browsers/account-a", ""); response.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
_, kept := store.envs["account-a"]
|
|
if len(store.deleted) != 1 || kept {
|
|
t.Fatalf("env must be removed with the container, deleted=%v", store.deleted)
|
|
}
|
|
}
|
|
|
|
func TestGatewayAndImageCRUDRoutes(t *testing.T) {
|
|
store := newMemoryStore()
|
|
gateway := &fakeGateway{token: "unit-test-gateway-token"}
|
|
app := newTestApp(t, store, gateway)
|
|
|
|
if response := do(app, http.MethodPost, "/api/browser-images",
|
|
`{"version":"148.0.7778.215","image_ref":"registry.example/browser:148","note":"main","enabled":true}`); response.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201 for image create, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
if image, ok := store.images["148.0.7778.215"]; !ok || image.ImageRef != "registry.example/browser:148" || !image.Enabled {
|
|
t.Fatalf("image must be stored: %#v", store.images)
|
|
}
|
|
if response := do(app, http.MethodPost, "/api/browser-images",
|
|
`{"version":"148.0.7778.215","image_ref":"registry.example/browser:148"}`); response.Code != http.StatusCreated {
|
|
t.Fatalf("enabled must default to true, got %d", response.Code)
|
|
}
|
|
}
|
|
|
|
func TestCreateImageReturnsJSONOverHTTP(t *testing.T) {
|
|
store := newMemoryStore()
|
|
app := fiber.New()
|
|
registerHub(app, store)
|
|
server := httptest.NewServer(adaptor.FiberApp(app))
|
|
defer server.Close()
|
|
|
|
response, err := server.Client().Post(server.URL+"/api/browser-images", "application/json", strings.NewReader(
|
|
`{"version":"148.0.7778.215","image_ref":"registry.example/browser:148","note":"main","enabled":true}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer response.Body.Close()
|
|
var image hub.Image
|
|
if err := json.NewDecoder(response.Body).Decode(&image); err != nil {
|
|
t.Fatalf("201 response must be JSON: %v", err)
|
|
}
|
|
if response.StatusCode != http.StatusCreated || !strings.HasPrefix(response.Header.Get("Content-Type"), "application/json") ||
|
|
image.Version != "148.0.7778.215" || image.ImageRef != "registry.example/browser:148" || !image.Enabled {
|
|
t.Fatalf("unexpected create response: status=%d content-type=%q image=%#v", response.StatusCode, response.Header.Get("Content-Type"), image)
|
|
}
|
|
}
|