Files
creator-hub/cmd/control-plane/hub_test.go
T
rogee e5c720e7d5 feat(gateways): 网关列表增加连通性与健康状态探测展示
- GET /api/gateways 并发探测 /healthz(连通性)与 /v1/browsers(健康),失败原因随列表返回
- 前端新增连通性/健康两列,失败原因悬浮提示
- 覆盖健康、令牌被拒、离线三类探测路径的单元测试
2026-09-03 12:44:49 +08:00

4777 lines
217 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"sort"
"strings"
"sync"
"testing"
"time"
"git.ipao.vip/rogee/creator-hub/internal/hub"
"git.ipao.vip/rogee/creator-hub/internal/phasea"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/adaptor"
)
// memoryStore 是 hubStore 的内存桩,记录写入以便断言编排副作用。
type memoryStore struct {
mu sync.Mutex
locksMu sync.Mutex
locks map[string]*sync.Mutex
gateways map[string]hub.Gateway
images map[string]hub.Image
envs map[string]hub.Env
exits map[string]hub.NetworkExit
bindings map[string]hub.EnvironmentContext
actions []hub.EnvironmentAction
upgraded map[string]string
upgradeErr error
releaseErr error
cleanupPendingErr error
cleanupPendingErrAfterMutation bool
gatewayFn func(name string) (hub.Gateway, error)
}
type blockingRuntimeStopStore struct {
*hub.Store
entered chan struct{}
release chan struct{}
}
func (s *blockingRuntimeStopStore) GetGateway(context.Context, string) (hub.Gateway, error) {
s.entered <- struct{}{}
<-s.release
return hub.Gateway{}, errors.New("gateway unavailable")
}
func newMemoryStore() *memoryStore {
return &memoryStore{
locks: map[string]*sync.Mutex{},
gateways: map[string]hub.Gateway{},
images: map[string]hub.Image{},
envs: map[string]hub.Env{},
exits: map[string]hub.NetworkExit{
"exit-1": {ID: "exit-1", Protocol: "socks5", Host: "127.0.0.1", Port: 1080, HealthStatus: "healthy", Version: 1},
},
bindings: map[string]hub.EnvironmentContext{},
upgraded: map[string]string{},
}
}
func TestResumeBlockReasonIsStable(t *testing.T) {
account := phasea.Account{AuthorizationStatus: "authorized"}
healthy := hub.EnvironmentContext{Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "healthy"}}
for name, test := range map[string]struct {
account phasea.Account
env hub.EnvironmentContext
found bool
want string
}{
"revoked": {phasea.Account{AuthorizationStatus: "revoked"}, healthy, true, "account_revoked"},
"missing binding": {account, hub.EnvironmentContext{}, false, "binding_missing"},
"direct exit": {account, hub.EnvironmentContext{}, true, "account_conflict"},
"unhealthy exit": {account, hub.EnvironmentContext{Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "unhealthy"}}, true, "network_exit_unhealthy"},
"cleanup pending": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeCleanupPending: true}, true, "runtime_stop_pending"},
"runtime active": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeInstanceID: "runtime-a"}, true, "runtime_active"},
} {
t.Run(name, func(t *testing.T) {
if got := resumeBlockReason(test.account, test.env, test.found); got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
func TestPhaseAReadinessErrorsAreStructured(t *testing.T) {
for _, test := range []struct {
name, reason string
unavailable bool
status int
}{
{name: "version conflict", reason: "draft_version_changed", status: http.StatusConflict},
{name: "resource unavailable", reason: "network_exit_unhealthy", unavailable: true, status: http.StatusServiceUnavailable},
} {
t.Run(test.name, func(t *testing.T) {
app := fiber.New()
app.Get("/", func(c fiber.Ctx) error {
return phaseAError(c, &phasea.ReadinessError{Reason: test.reason, Unavailable: test.unavailable})
})
response := do(app, http.MethodGet, "/", "")
if response.Code != test.status || !strings.Contains(response.Body.String(), `"reason_code":"`+test.reason+`"`) {
t.Fatalf("unexpected response: %d %s", response.Code, response.Body.String())
}
})
}
}
func (s *memoryStore) LockResources(_ context.Context, aliases, exitIDs, imageVersions []string) (func(), error) {
keys := make([]string, 0, len(aliases)+len(exitIDs)+len(imageVersions))
for _, alias := range aliases {
keys = append(keys, "environment:"+alias)
}
for _, id := range exitIDs {
keys = append(keys, "network-exit:"+id)
}
for _, version := range imageVersions {
keys = append(keys, "image:"+version)
}
sort.Strings(keys)
unlocks := make([]func(), 0, len(keys))
seen := map[string]bool{}
for _, key := range keys {
if !seen[key] {
seen[key] = true
unlocks = append(unlocks, s.lock(key))
}
}
return func() {
for index := len(unlocks) - 1; index >= 0; index-- {
unlocks[index]()
}
}, nil
}
func (s *memoryStore) lock(key string) func() {
s.locksMu.Lock()
lock := s.locks[key]
if lock == nil {
lock = &sync.Mutex{}
s.locks[key] = lock
}
s.locksMu.Unlock()
lock.Lock()
return lock.Unlock
}
func (s *memoryStore) CreateGateway(_ context.Context, _, _, _ string) (hub.Gateway, error) {
return hub.Gateway{}, nil
}
func (s *memoryStore) ListGateways(context.Context) ([]hub.Gateway, error) {
s.mu.Lock()
defer s.mu.Unlock()
gateways := make([]hub.Gateway, 0, len(s.gateways))
for _, gateway := range s.gateways {
gateways = append(gateways, gateway)
}
return gateways, 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 s.bindings[alias].RuntimeCleanupPending {
return hub.ErrConflict
}
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
bound, ok := s.bindings[alias]
if !ok {
bound = hub.EnvironmentContext{Env: env, AccountID: alias, BindingID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]}
}
bound.Env, bound.BindingVersion = env, bound.BindingVersion+1
s.bindings[alias] = bound
return nil
}
func (s *memoryStore) CreateNetworkExit(_ context.Context, exit hub.NetworkExit, credentialID string) (hub.NetworkExit, error) {
s.mu.Lock()
defer s.mu.Unlock()
exit.ID, exit.HealthStatus, exit.Version = "exit-created", "unchecked", 1
if credentialID != "" {
exit.CredentialReference = &hub.CredentialReference{ID: credentialID, Provider: "os_keyring"}
}
s.exits[exit.ID] = exit
return exit, nil
}
func (s *memoryStore) ListNetworkExits(context.Context) ([]hub.NetworkExit, error) {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]hub.NetworkExit, 0, len(s.exits))
for _, exit := range s.exits {
result = append(result, exit)
}
return result, nil
}
func (s *memoryStore) GetNetworkExit(_ context.Context, id string) (hub.NetworkExit, error) {
s.mu.Lock()
defer s.mu.Unlock()
exit, ok := s.exits[id]
if !ok {
return hub.NetworkExit{}, hub.ErrNotFound
}
return exit, nil
}
func (s *memoryStore) GetNetworkExitAccess(ctx context.Context, id string) (hub.NetworkExitAccess, error) {
exit, err := s.GetNetworkExit(ctx, id)
return hub.NetworkExitAccess{NetworkExit: exit}, err
}
func (s *memoryStore) RecordNetworkExitCheck(_ context.Context, id string, observation hub.ExitObservation, failure string) (hub.NetworkExit, string, error) {
s.mu.Lock()
defer s.mu.Unlock()
exit, ok := s.exits[id]
if !ok {
return hub.NetworkExit{}, "exit_unavailable", hub.ErrNotFound
}
reason := failure
if reason == "" && exit.ExpectedPublicIP != "" && exit.ExpectedPublicIP != observation.PublicIP {
reason = "exit_ip_drift"
}
if reason == "" && exit.ExpectedRegion != "" && exit.ExpectedRegion != observation.Region {
reason = "exit_region_drift"
}
exit.ObservedPublicIP, exit.ObservedRegion = observation.PublicIP, observation.Region
if reason == "" {
exit.HealthStatus, reason = "healthy", "exit_healthy"
} else {
exit.HealthStatus = "unhealthy"
}
s.exits[id] = exit
return exit, reason, nil
}
func (s *memoryStore) DisableNetworkExit(_ context.Context, id string) (hub.NetworkExit, error) {
s.mu.Lock()
defer s.mu.Unlock()
exit, ok := s.exits[id]
if !ok {
return hub.NetworkExit{}, hub.ErrNotFound
}
exit.HealthStatus = "disabled"
s.exits[id] = exit
return exit, nil
}
func (s *memoryStore) CreateBoundEnv(ctx context.Context, env hub.Env, accountID, exitID string) (hub.EnvironmentContext, bool, error) {
s.mu.Lock()
if existing, ok := s.bindings[env.Alias]; ok {
if existing.AccountStatus == "" {
existing.AccountStatus, existing.AuthorizationStatus = "active", "authorized"
}
s.mu.Unlock()
return existing, false, nil
}
s.mu.Unlock()
if err := s.CreateEnv(ctx, env); err != nil {
return hub.EnvironmentContext{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
bound := hub.EnvironmentContext{Env: env, AccountID: accountID, AccountStatus: "active", AuthorizationStatus: "authorized",
BindingID: accountID, BindingVersion: 1, Exit: s.exits[exitID]}
s.bindings[env.Alias] = bound
return bound, true, nil
}
func (s *memoryStore) GetEnvironmentContext(_ context.Context, alias string) (hub.EnvironmentContext, error) {
s.mu.Lock()
defer s.mu.Unlock()
if bound, ok := s.bindings[alias]; ok {
if bound.AccountStatus == "" {
bound.AccountStatus, bound.AuthorizationStatus = "active", "authorized"
}
return bound, nil
}
env, ok := s.envs[alias]
if !ok {
return hub.EnvironmentContext{}, hub.ErrNotFound
}
return hub.EnvironmentContext{Env: env, AccountID: alias, AccountStatus: "active", AuthorizationStatus: "authorized",
BindingID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]}, nil
}
func (s *memoryStore) GetEnvironmentContextForAccount(ctx context.Context, accountID string) (hub.EnvironmentContext, error) {
s.mu.Lock()
for alias, bound := range s.bindings {
if bound.AccountID == accountID {
s.mu.Unlock()
return s.GetEnvironmentContext(ctx, alias)
}
}
s.mu.Unlock()
return hub.EnvironmentContext{}, hub.ErrNotFound
}
func (s *memoryStore) ValidateEnvironmentRebind(_ context.Context, alias, exitID string, expectedBindingVersion int64) error {
s.mu.Lock()
defer s.mu.Unlock()
bound, ok := s.bindings[alias]
if !ok {
bound.BindingVersion = 1
}
if bound.BindingVersion != expectedBindingVersion || bound.RuntimeCleanupPending || s.exits[exitID].HealthStatus != "healthy" {
return hub.ErrConflict
}
return nil
}
func (s *memoryStore) RebindEnvironment(_ context.Context, alias, exitID, runtimeID string, expectedBindingVersion int64, networkIDs ...string) (hub.EnvironmentContext, error) {
s.mu.Lock()
defer s.mu.Unlock()
bound, ok := s.bindings[alias]
if !ok {
bound = hub.EnvironmentContext{Env: s.envs[alias], AccountID: alias, BindingID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]}
}
if bound.BindingVersion != expectedBindingVersion || bound.RuntimeCleanupPending {
return hub.EnvironmentContext{}, hub.ErrConflict
}
bound.RuntimeInstanceID, bound.RuntimeID, bound.RuntimeNetworkID = "", "", ""
if runtimeID != "" {
bound.RuntimeInstanceID, bound.RuntimeID = "runtime-instance", runtimeID
if len(networkIDs) == 1 {
bound.RuntimeNetworkID = networkIDs[0]
}
}
bound.Exit, bound.BindingVersion = s.exits[exitID], bound.BindingVersion+1
s.bindings[alias] = bound
return bound, nil
}
func (s *memoryStore) ActivateRuntime(_ context.Context, alias, runtimeID string, bindingVersion int64, exitID string, networkIDs ...string) (hub.EnvironmentContext, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(networkIDs) != 1 || networkIDs[0] == "" {
return hub.EnvironmentContext{}, hub.ErrInvalid
}
bound, ok := s.bindings[alias]
if !ok {
bound = hub.EnvironmentContext{Env: s.envs[alias], AccountID: alias, BindingID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]}
}
if bound.AccountStatus == "" {
bound.AccountStatus, bound.AuthorizationStatus = "active", "authorized"
}
if bound.AccountStatus != "active" || bound.AuthorizationStatus != "authorized" ||
bound.BindingVersion != bindingVersion || bound.Exit.ID != exitID || bound.RuntimeCleanupPending {
return hub.EnvironmentContext{}, hub.ErrConflict
}
bound.RuntimeInstanceID, bound.RuntimeID = "runtime-instance", runtimeID
bound.RuntimeNetworkID = networkIDs[0]
s.bindings[alias] = bound
return bound, nil
}
func (s *memoryStore) ReleaseRuntime(_ context.Context, environment hub.EnvironmentContext) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.releaseErr != nil {
return s.releaseErr
}
bound, ok := s.bindings[environment.Alias]
if !ok {
bound = hub.EnvironmentContext{Env: s.envs[environment.Alias], AccountID: environment.Alias,
BindingID: environment.Alias, BindingVersion: 1, Exit: s.exits["exit-1"]}
}
if environment.RuntimeInstanceID == "" {
return nil
}
if bound.BindingID != environment.BindingID || bound.BindingVersion != environment.BindingVersion ||
bound.RuntimeInstanceID != environment.RuntimeInstanceID {
return hub.ErrConflict
}
bound.RuntimeInstanceID, bound.RuntimeID, bound.RuntimeNetworkID = "", "", ""
s.bindings[environment.Alias] = bound
return nil
}
func (s *memoryStore) SetRuntimeCleanupPending(_ context.Context, environment hub.EnvironmentContext, pending bool) error {
s.mu.Lock()
defer s.mu.Unlock()
if pending && environment.RuntimeCleanupRuntimeID == "" {
return hub.ErrInvalid
}
if s.cleanupPendingErr != nil && !s.cleanupPendingErrAfterMutation {
return s.cleanupPendingErr
}
if pending && s.releaseErr != nil {
return s.releaseErr
}
bound, ok := s.bindings[environment.Alias]
if !ok {
bound = hub.EnvironmentContext{Env: s.envs[environment.Alias], AccountID: environment.Alias,
BindingID: environment.Alias, BindingVersion: 1, Exit: s.exits["exit-1"]}
}
if bound.BindingID != environment.BindingID || bound.BindingVersion != environment.BindingVersion {
return hub.ErrConflict
}
if bound.RuntimeCleanupPending {
if bound.RuntimeCleanupBindingVersion != environment.RuntimeCleanupBindingVersion ||
bound.RuntimeCleanupInstanceID != environment.RuntimeCleanupInstanceID ||
bound.RuntimeCleanupRuntimeID != environment.RuntimeCleanupRuntimeID ||
bound.RuntimeCleanupNetworkID != environment.RuntimeCleanupNetworkID {
return hub.ErrConflict
}
} else if pending && bound.RuntimeInstanceID != environment.RuntimeCleanupInstanceID {
return hub.ErrConflict
}
bound.RuntimeCleanupPending = pending
if pending {
bound.RuntimeCleanupBindingVersion = environment.RuntimeCleanupBindingVersion
bound.RuntimeCleanupInstanceID = environment.RuntimeCleanupInstanceID
bound.RuntimeCleanupRuntimeID = environment.RuntimeCleanupRuntimeID
bound.RuntimeCleanupNetworkID = environment.RuntimeCleanupNetworkID
bound.RuntimeInstanceID, bound.RuntimeID, bound.RuntimeNetworkID = "", "", ""
} else {
bound.RuntimeCleanupBindingVersion = 0
bound.RuntimeCleanupInstanceID, bound.RuntimeCleanupRuntimeID, bound.RuntimeCleanupNetworkID = "", "", ""
}
s.bindings[environment.Alias] = bound
return s.cleanupPendingErr
}
func (s *memoryStore) AppendEnvironmentAction(_ context.Context, _ string, action hub.EnvironmentAction) error {
s.mu.Lock()
defer s.mu.Unlock()
s.actions = append(s.actions, action)
return nil
}
func TestPhaseARuntimeLeaseRoutesAreInternal(t *testing.T) {
app := fiber.New()
registerPhaseA(app, nil, nil, nil)
for _, request := range []struct {
method, path string
}{
{http.MethodPost, "/api/phase-a/runtimes"},
{http.MethodDelete, "/api/phase-a/runtimes/runtime-a"},
} {
if response := do(app, request.method, request.path, `{}`); response.Code != http.StatusNotFound {
t.Fatalf("%s %s remained public: %d", request.method, request.path, response.Code)
}
}
}
func TestPauseSerializesResumeAndRetainsClaimGateOnUnknownStop(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
fixture := newPostgresRebindFixture(t, databaseURL)
accountStore, err := phasea.Open(ctx, fixture.databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = accountStore.Close() })
if err := accountStore.ResumeAccount(ctx, "account-a"); err != nil {
t.Fatal(err)
}
if _, err := fixture.store.ActivateRuntime(ctx, "account-a", "old-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old"); err != nil {
t.Fatal(err)
}
blocking := &blockingRuntimeStopStore{Store: fixture.store, entered: make(chan struct{}, 1), release: make(chan struct{})}
app := fiber.New()
registerPhaseA(app, accountStore, blocking, nil)
pauseDone := make(chan *httptest.ResponseRecorder, 1)
resumeDone := make(chan *httptest.ResponseRecorder, 1)
go func() { pauseDone <- do(app, http.MethodPost, "/api/phase-a/accounts/account-a/pause", "") }()
select {
case <-blocking.entered:
case <-time.After(5 * time.Second):
t.Fatal("pause did not enter runtime stop")
}
go func() { resumeDone <- do(app, http.MethodPost, "/api/phase-a/accounts/account-a/resume", "") }()
select {
case response := <-resumeDone:
t.Fatalf("resume bypassed the runtime operation lock: %d", response.Code)
case <-time.After(time.Second):
}
close(blocking.release)
if response := <-pauseDone; response.Code != http.StatusInternalServerError {
t.Fatalf("unknown stop returned %d: %s", response.Code, response.Body.String())
}
if response := <-resumeDone; response.Code != http.StatusConflict {
t.Fatalf("resume reopened an unconverged lease: %d: %s", response.Code, response.Body.String())
}
account, err := accountStore.GetAccount(ctx, "account-a")
if err != nil || account.RuntimeStatus != "paused" {
t.Fatalf("resume reopened the claim gate: account=%#v err=%v", account, err)
}
}
type recordedRequest struct {
method string
path string
body map[string]any
}
// fakeGateway 模拟 docker-gateway:按路由表应答并记录请求。
type fakeGateway struct {
mu sync.Mutex
createOnce sync.Once
deleteOnce sync.Once
token string
requests []recordedRequest
containers []containerStatus
failCreate int // 前 N 次 create 返回失败
failCreateStatus int
failDelete int // 前 N 次 delete 返回 500 且保留容器
deleteNotFound int
failProxy bool
createStarted chan struct{}
releaseCreate <-chan struct{}
deleteDone chan struct{}
releaseDelete <-chan struct{}
cleanupPending int
disconnectDelete int
disconnectList int
failList int
invalidList int
invalidListBody string
readErrorList int
disconnectListAfterDelete bool
}
type fakeExitProbe struct {
observation hub.ExitObservation
failure string
}
type blockingExitProbe struct {
started chan struct{}
release <-chan struct{}
once sync.Once
}
func (probe *blockingExitProbe) Check(context.Context, hub.NetworkExitAccess) (hub.ExitObservation, string) {
probe.once.Do(func() { close(probe.started) })
<-probe.release
return hub.ExitObservation{PublicIP: "203.0.113.1", Region: "test"}, ""
}
func (probe fakeExitProbe) Check(context.Context, hub.NetworkExitAccess) (hub.ExitObservation, string) {
if probe.observation.PublicIP == "" && probe.failure == "" {
probe.observation = hub.ExitObservation{PublicIP: "203.0.113.1", Region: "test"}
}
return probe.observation, probe.failure
}
type sequenceExitProbe struct {
calls int
failures []string
}
func (probe *sequenceExitProbe) Check(context.Context, hub.NetworkExitAccess) (hub.ExitObservation, string) {
failure := ""
if probe.calls < len(probe.failures) {
failure = probe.failures[probe.calls]
}
probe.calls++
return hub.ExitObservation{PublicIP: "203.0.113.1", Region: "test"}, failure
}
func (g *fakeGateway) handler(t *testing.T) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodGet && request.URL.Path == "/healthz" {
response.WriteHeader(http.StatusNoContent)
return
}
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--
status := http.StatusConflict
if g.failCreateStatus != 0 {
status = g.failCreateStatus
}
g.mu.Unlock()
response.WriteHeader(status)
_, _ = 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
}
state, proxyReady := "running", true
networkID := "network-id"
if stopped, _ := body["stopped"].(bool); stopped {
state, proxyReady, networkID = "exited", false, ""
}
g.mu.Lock()
g.containers = []containerStatus{{
ID: "container-id", Alias: body["alias"].(string), State: state, Status: state, ProxyReady: proxyReady,
BindingVersion: int64(body["binding_version"].(float64)), NetworkExitID: body["network_exit_id"].(string),
NetworkID: networkID,
}}
g.mu.Unlock()
response.WriteHeader(http.StatusCreated)
_, _ = response.Write([]byte(`{"id":"container-id","alias":"account-a","network_id":"` + networkID + `"}`))
case request.Method == http.MethodGet && request.URL.Path == "/v1/browsers":
g.mu.Lock()
if g.failList > 0 {
g.failList--
g.mu.Unlock()
response.WriteHeader(http.StatusInternalServerError)
_, _ = response.Write([]byte(`{"error":"docker unavailable"}`))
return
}
if g.invalidList > 0 {
g.invalidList--
body := g.invalidListBody
if body == "" {
body = `{"not":"a browser list"}`
}
g.mu.Unlock()
_, _ = response.Write([]byte(body))
return
}
if g.readErrorList > 0 {
g.readErrorList--
g.mu.Unlock()
connection, _, _ := response.(http.Hijacker).Hijack()
_, _ = connection.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 64\r\n\r\n[]"))
_ = connection.Close()
return
}
if g.disconnectList > 0 {
g.disconnectList--
g.mu.Unlock()
connection, _, _ := response.(http.Hijacker).Hijack()
_ = connection.Close()
return
}
containers := append([]containerStatus{}, g.containers...)
g.mu.Unlock()
_ = json.NewEncoder(response).Encode(containers)
case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/v1/browsers/"):
g.mu.Lock()
if len(g.containers) > 0 && (body["runtime_id"] != g.containers[0].ID ||
int64(body["binding_version"].(float64)) != g.containers[0].BindingVersion) {
g.mu.Unlock()
response.WriteHeader(http.StatusConflict)
return
}
if g.failDelete > 0 {
g.failDelete--
g.mu.Unlock()
response.WriteHeader(http.StatusInternalServerError)
_, _ = response.Write([]byte(`{"error":"docker delete failed"}`))
return
}
g.containers = nil
if g.deleteNotFound > 0 {
g.deleteNotFound--
g.mu.Unlock()
response.WriteHeader(http.StatusNotFound)
return
}
g.mu.Unlock()
if g.deleteDone != nil {
g.deleteOnce.Do(func() { close(g.deleteDone) })
}
if g.releaseDelete != nil {
<-g.releaseDelete
}
g.mu.Lock()
if g.cleanupPending > 0 {
g.cleanupPending--
g.mu.Unlock()
response.WriteHeader(http.StatusAccepted)
return
}
if g.disconnectDelete > 0 {
g.disconnectDelete--
if g.disconnectListAfterDelete {
g.disconnectList++
}
g.mu.Unlock()
connection, _, _ := response.(http.Hijacker).Hijack()
_ = connection.Close()
return
}
g.mu.Unlock()
response.WriteHeader(http.StatusNoContent)
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/start"):
g.mu.Lock()
if len(g.containers) > 0 {
g.containers[0].State, g.containers[0].Status = "running", "Up"
}
g.mu.Unlock()
response.WriteHeader(http.StatusNoContent)
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/stop"):
g.mu.Lock()
if len(g.containers) > 0 && (body["runtime_id"] != g.containers[0].ID ||
int64(body["binding_version"].(float64)) != g.containers[0].BindingVersion) {
g.mu.Unlock()
response.WriteHeader(http.StatusConflict)
return
}
if len(g.containers) > 0 {
g.containers[0].State, g.containers[0].Status = "exited", "Exited"
}
g.mu.Unlock()
response.WriteHeader(http.StatusNoContent)
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/proxy"):
g.mu.Lock()
if len(g.containers) > 0 && body["runtime_id"] != g.containers[0].ID {
g.mu.Unlock()
response.WriteHeader(http.StatusConflict)
return
}
if g.failProxy {
g.mu.Unlock()
response.WriteHeader(http.StatusBadGateway)
return
}
if len(g.containers) > 0 {
g.containers[0].ProxyReady = true
}
g.mu.Unlock()
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 {
return newTestAppWithNetwork(t, store, gateway, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
}
func newTestAppWithNetwork(t *testing.T, store *memoryStore, gateway *fakeGateway, probe networkExitProbe,
resolve func(hub.NetworkExitAccess) (string, error)) *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()
registerHubWithNetwork(app, store, probe, resolve)
return app
}
func do(app *fiber.App, method, path, body string, credentials ...string) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
request := httptest.NewRequest(method, path, reader)
if len(credentials) == 2 {
request.SetBasicAuth(credentials[0], credentials[1])
}
adaptor.FiberApp(app).ServeHTTP(response, request)
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"},"account_id":"account-a","network_exit_id":"exit-1"}`
func TestListGatewaysExposesConnectivityAndHealth(t *testing.T) {
store := newMemoryStore()
// gw-1:正常网关,/healthz 可达且 /v1/browsers 可认证。
gateway := &fakeGateway{token: "unit-test-gateway-token"}
server := httptest.NewServer(gateway.handler(t))
t.Cleanup(server.Close)
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
// gw-auth:/healthz 可达但令牌被拒,应区分连通与 API 健康。
authServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodGet && request.URL.Path == "/healthz" {
response.WriteHeader(http.StatusNoContent)
return
}
response.WriteHeader(http.StatusUnauthorized)
}))
t.Cleanup(authServer.Close)
store.gateways["gw-auth"] = hub.Gateway{Name: "gw-auth", Endpoint: authServer.URL, Token: "unit-test-gateway-token"}
// gw-offline:端口已关闭,两个探测都应失败并携带原因。
closed := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
closedURL := closed.URL
closed.Close()
store.gateways["gw-offline"] = hub.Gateway{Name: "gw-offline", Endpoint: closedURL, Token: "offline-token-123456789"}
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodGet, "/api/gateways", "")
if response.Code != http.StatusOK {
t.Fatalf("list gateways returned %d: %s", response.Code, response.Body.String())
}
var statuses []gatewayStatus
if err := json.Unmarshal(response.Body.Bytes(), &statuses); err != nil || len(statuses) != 3 {
t.Fatalf("decode gateway statuses failed: statuses=%#v err=%v", statuses, err)
}
byName := map[string]gatewayStatus{}
for _, status := range statuses {
byName[status.Name] = status
}
if status := byName["gw-1"]; status.Connectivity != "online" || status.Health != "healthy" || status.ConnectivityReason != "" || status.HealthReason != "" {
t.Fatalf("healthy gateway reported %#v", status)
}
if status := byName["gw-auth"]; status.Connectivity != "online" || status.Health != "unhealthy" || status.HealthReason == "" {
t.Fatalf("auth-rejected gateway reported %#v", status)
}
if status := byName["gw-offline"]; status.Connectivity != "offline" || status.Health != "unhealthy" || status.ConnectivityReason == "" || status.HealthReason == "" {
t.Fatalf("offline gateway reported %#v", status)
}
}
func TestParseGatewayBrowserListStrict(t *testing.T) {
for _, test := range []struct {
name string
body string
ok bool
}{
{name: "empty", body: `[]`, ok: true},
{name: "minimal browser", body: `[{"id":"container-id","alias":"account-a","state":"running"}]`, ok: true},
{name: "top-level null", body: `null`},
{name: "null element", body: `[null]`},
{name: "missing id", body: `[{"alias":"account-a","state":"running"}]`},
{name: "missing alias", body: `[{"id":"container-id","state":"running"}]`},
{name: "missing state", body: `[{"id":"container-id","alias":"account-a"}]`},
{name: "duplicate alias", body: `[{"id":"one","alias":"account-a","state":"running"},{"id":"two","alias":"account-a","state":"exited"}]`},
} {
t.Run(test.name, func(t *testing.T) {
browsers, err := parseGatewayBrowserList([]byte(test.body))
if (err == nil) != test.ok {
t.Fatalf("parse result browsers=%#v err=%v", browsers, err)
}
})
}
}
func TestGatewayCallPropagatesBodyReadError(t *testing.T) {
gateway := &fakeGateway{token: "unit-test-gateway-token", readErrorList: 1}
server := httptest.NewServer(gateway.handler(t))
defer server.Close()
status, body, err := gatewayCall(context.Background(), hub.Gateway{Endpoint: server.URL, Token: gateway.token},
http.MethodGet, "/v1/browsers", nil, time.Second)
if status != http.StatusOK || string(body) != "[]" || err == nil {
t.Fatalf("partial response was accepted: status=%d body=%q err=%v", status, body, err)
}
}
func TestGatewayCreatePayloadStripsLegacyProxyFingerprint(t *testing.T) {
payload := gatewayCreatePayload(hub.EnvironmentContext{Env: hub.Env{Alias: "account-a", Name: "甲", Fingerprint: hub.Fingerprint{
Seed: 1, ProxyServer: "http://legacy:secret@proxy.example:8080", DisableNonProxiedUDP: true,
}}, BindingVersion: 1, Exit: hub.NetworkExit{ID: "exit-1"}}, "registry.example/browser:1", gatewayNetworkExit{Protocol: "http", Host: "proxy-2.example", Port: 8080})
encoded, _ := json.Marshal(payload)
if strings.Contains(string(encoded), "legacy") || strings.Contains(string(encoded), "secret") {
t.Fatalf("legacy proxy URI entered the gateway contract: %s", encoded)
}
}
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)
}
exit := payload["network_exit"].(map[string]any)
if exit["protocol"] != "socks5" || exit["host"] != "127.0.0.1" || exit["port"] != float64(1080) {
t.Fatalf("platform must force the bound exit: %#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)
}
if stored := store.envs["account-a"].Fingerprint; stored.ProxyServer != "" || stored.DisableNonProxiedUDP {
t.Fatalf("persistent fingerprint must not contain proxy material: %#v", stored)
}
if len(store.actions) != 2 || store.actions[0].OperationID != store.actions[1].OperationID ||
store.actions[0].Action != "create" || store.actions[1].Outcome != "succeeded" {
t.Fatalf("create must emit a correlated audit pair: %#v", store.actions)
}
}
func TestCreateBrowserSupportsDirectMachineExit(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"}
server := httptest.NewServer(gateway.handler(t))
defer server.Close()
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{failure: "must_not_probe"}, func(hub.NetworkExitAccess) (string, error) {
return "", errors.New("must not resolve direct exit credentials")
})
body := `{"alias":"direct-env","name":"直连环境","gateway":"gw-1","image_version":"148.0.7778.215",` +
`"fingerprint":{"seed":2024},"account_id":"account-a","network_exit_id":""}`
response := do(app, http.MethodPost, "/api/browsers", body)
if response.Code != http.StatusCreated {
t.Fatalf("expected direct create 201, got %d: %s", response.Code, response.Body.String())
}
bound := store.bindings["direct-env"]
if bound.Exit.ID != "" || bound.RuntimeID == "" {
t.Fatalf("direct binding was not activated: %#v", bound)
}
requests := gateway.recorded()
if len(requests) != 1 || requests[0].body["network_exit_id"] != "" {
t.Fatalf("direct create sent an unexpected gateway payload: %#v", requests)
}
exit, ok := requests[0].body["network_exit"].(map[string]any)
if !ok || exit["protocol"] != "" || exit["host"] != "" || exit["port"] != float64(0) {
t.Fatalf("direct create must not send proxy settings: %#v", requests[0].body)
}
}
func TestExitFailuresStopCreateBeforeGateway(t *testing.T) {
for _, test := range []struct {
name string
probe fakeExitProbe
reason string
}{
{name: "identity drift", probe: fakeExitProbe{observation: hub.ExitObservation{PublicIP: "203.0.113.11", Region: "test"}}, reason: "exit_ip_drift"},
{name: "authentication rejected", probe: fakeExitProbe{failure: "proxy_auth_failed"}, reason: "proxy_auth_failed"},
{name: "proxy disconnected", probe: fakeExitProbe{failure: "proxy_check_failed"}, reason: "proxy_check_failed"},
} {
t.Run(test.name, func(t *testing.T) {
store := newMemoryStore()
store.exits["exit-1"] = hub.NetworkExit{
ID: "exit-1", Protocol: "socks5", Host: "127.0.0.1", Port: 1080,
ExpectedPublicIP: "203.0.113.10", HealthStatus: "healthy", Version: 1,
}
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token"}
server := httptest.NewServer(gateway.handler(t))
defer server.Close()
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
app := fiber.New()
registerHubWithNetwork(app, store, test.probe, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
if response.Code != http.StatusConflict {
t.Fatalf("expected fail-close 409, got %d: %s", response.Code, response.Body.String())
}
if len(gateway.recorded()) != 0 {
t.Fatalf("exit failure must stop before any gateway start/create call: %#v", gateway.recorded())
}
bound, err := store.GetEnvironmentContext(context.Background(), "account-a")
if err != nil || bound.RuntimeInstanceID != "" || store.exits["exit-1"].HealthStatus != "unhealthy" {
t.Fatalf("failed create must retain only the stable inactive binding: %#v err=%v", bound, err)
}
if len(store.actions) != 2 || store.actions[1].Outcome != "failed" || store.actions[1].ReasonCode != test.reason {
t.Fatalf("exit failure must be auditable without secrets: %#v", store.actions)
}
})
}
}
func TestRepeatedCreateAndStartReuseStableEnvironment(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)
if response := do(app, http.MethodPost, "/api/browsers", createEnvBody); response.Code != http.StatusCreated {
t.Fatalf("initial create failed: %d %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodPost, "/api/browsers", createEnvBody); response.Code != http.StatusOK {
t.Fatalf("idempotent create failed: %d %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent {
t.Fatalf("idempotent start failed: %d %s", response.Code, response.Body.String())
}
bound := store.bindings["account-a"]
if bound.Alias != "account-a" || bound.Exit.ID != "exit-1" || bound.RuntimeID != "container-id" {
t.Fatalf("repeated actions changed stable environment identity: %#v", bound)
}
createCalls := 0
for _, request := range gateway.recorded() {
if request.method == http.MethodPost && request.path == "/v1/browsers" {
createCalls++
if request.body["volume"] != "creatorhub-profile-account-a" || request.body["network_exit"].(map[string]any)["host"] != "127.0.0.1" {
t.Fatalf("create changed Profile volume or exit: %#v", request.body)
}
}
}
if createCalls != 1 {
t.Fatalf("idempotent create called gateway create %d times", createCalls)
}
}
func TestCreateBrowserKeepsStableBindingWhenGatewayRejects(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 _, kept := store.envs["account-a"]; !kept {
t.Fatal("stable environment and Profile anchor must remain retryable")
}
}
func TestCreateBrowserTracksUnknownNetworkGenerationWithoutAliasCleanup(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, failCreateStatus: http.StatusBadGateway}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
if response.Code != http.StatusBadGateway {
t.Fatalf("expected failed gateway create, got %d: %s", response.Code, response.Body.String())
}
requests := gateway.recorded()
if len(requests) != 1 || requests[0].method != http.MethodPost {
t.Fatalf("unknown network generation was discovered or cleaned by mutable alias: %#v", requests)
}
environment, err := store.GetEnvironmentContext(context.Background(), "account-a")
if err != nil || !environment.RuntimeCleanupPending || environment.RuntimeCleanupRuntimeID != missingRuntimeID || environment.RuntimeCleanupNetworkID != "" {
t.Fatalf("unknown network generation was not retained for manual reconciliation: environment=%#v err=%v", environment, err)
}
}
func TestReconcileGatewayCreateDoesNotReuseOldNetworkGeneration(t *testing.T) {
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1"}
environment := hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active",
AuthorizationStatus: "authorized", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"],
RuntimeInstanceID: "runtime-instance-c1", RuntimeID: "container-c1", RuntimeNetworkID: "network-n1"}
store.bindings[environment.Alias] = environment
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "container-c1", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: "network-n1", ProxyReady: true,
}}}
server := httptest.NewServer(gateway.handler(t))
defer server.Close()
target := hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
err := reconcileGatewayCreate(context.Background(), store, target, environment, []byte(`{"error":"result unknown"}`))
after := store.bindings[environment.Alias]
if !errors.Is(err, hub.ErrConflict) || len(gateway.recorded()) != 0 || len(gateway.containers) != 1 ||
!after.RuntimeCleanupPending || after.RuntimeCleanupRuntimeID != missingRuntimeID || after.RuntimeCleanupNetworkID != "" {
t.Fatalf("unknown create reused the old generation: err=%v requests=%#v containers=%#v environment=%#v",
err, gateway.recorded(), gateway.containers, after)
}
}
func TestValidCreatedRuntimeGeneration(t *testing.T) {
environment := hub.EnvironmentContext{RuntimeNetworkID: "network-n1"}
for _, test := range []struct {
name string
created containerStatus
environment hub.EnvironmentContext
running bool
want bool
}{
{name: "active matching generation", created: containerStatus{ID: "container-c1", NetworkID: "network-n1"}, running: true, want: true},
{name: "active empty runtime", created: containerStatus{NetworkID: "network-n1"}, running: true},
{name: "active invalid runtime", created: containerStatus{ID: "container c1", NetworkID: "network-n1"}, running: true},
{name: "active empty network", created: containerStatus{ID: "container-c1"}, running: true},
{name: "active invalid network", created: containerStatus{ID: "container-c1", NetworkID: "network n1"}, running: true},
{name: "active replacement network", created: containerStatus{ID: "container-c1", NetworkID: "network-n2"}, running: true},
{name: "active existing matching generation", created: containerStatus{ID: "container-c1", NetworkID: "network-n1"}, running: true, want: true,
environment: hub.EnvironmentContext{RuntimeInstanceID: "runtime-instance-c1", RuntimeID: "container-c1", RuntimeNetworkID: "network-n1"}},
{name: "active existing successor container", created: containerStatus{ID: "container-c2", NetworkID: "network-n1"}, running: true,
environment: hub.EnvironmentContext{RuntimeInstanceID: "runtime-instance-c1", RuntimeID: "container-c1", RuntimeNetworkID: "network-n1"}},
{name: "active legacy lease without network", created: containerStatus{ID: "container-c2", NetworkID: "network-n2"}, running: true,
environment: hub.EnvironmentContext{RuntimeInstanceID: "runtime-instance-c1", RuntimeID: "container-c1"}},
{name: "stopped empty network", created: containerStatus{ID: "container-c1"}, want: true},
{name: "stopped invalid network", created: containerStatus{ID: "container-c1", NetworkID: "network n1"}},
} {
t.Run(test.name, func(t *testing.T) {
current := environment
if test.environment.RuntimeInstanceID != "" {
current = test.environment
}
if got := validCreatedRuntime(test.created, current, test.running); got != test.want {
t.Fatalf("validCreatedRuntime(%#v, running=%v)=%v, want %v", test.created, test.running, got, test.want)
}
})
}
}
func TestLegacyActiveRuntimeWithoutNetworkGenerationDoesNotTouchSuccessor(t *testing.T) {
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1"}
environment := hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active",
AuthorizationStatus: "authorized", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"],
RuntimeInstanceID: "runtime-instance-c1", RuntimeID: "container-c1"}
store.bindings[environment.Alias] = environment
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "container-c2", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: "network-n2", ProxyReady: true,
}}}
server := httptest.NewServer(gateway.handler(t))
defer server.Close()
target := hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
if containerMatchesBinding(gateway.containers[0], environment) {
t.Fatal("legacy active lease accepted a successor network generation")
}
if _, err := activateGatewayRuntime(context.Background(), store, target, environment, "container-c2", "network-n2"); !errors.Is(err, hub.ErrConflict) {
t.Fatalf("successor activation did not fail closed: %v", err)
}
if _, err := removeGatewayRuntime(context.Background(), store, target, environment); !errors.Is(err, hub.ErrConflict) {
t.Fatalf("legacy cleanup did not fence the successor: %v", err)
}
after := store.bindings[environment.Alias]
requests := gateway.recorded()
if len(requests) != 1 || requests[0].method != http.MethodGet || len(gateway.containers) != 1 ||
after.RuntimeID != "container-c1" || after.RuntimeNetworkID != "" || after.RuntimeCleanupPending {
t.Fatalf("legacy C1 cleanup touched C2/N2: requests=%#v containers=%#v environment=%#v", requests, gateway.containers, after)
}
}
func TestCreateBrowserInvalid201TracksNetworkCleanupGeneration(t *testing.T) {
for _, networkID := range []string{"network-n1", ""} {
name := "known network"
if networkID == "" {
name = "unknown network"
}
t.Run(name, func(t *testing.T) {
var deleteBody map[string]any
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
}
switch request.Method {
case http.MethodPost:
response.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(response).Encode(map[string]string{"network_id": networkID})
case http.MethodGet:
_, _ = response.Write([]byte(`[]`))
case http.MethodDelete:
_ = json.NewDecoder(request.Body).Decode(&deleteBody)
if networkID == "" {
response.WriteHeader(http.StatusConflict)
return
}
response.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected gateway request %s %s", request.Method, request.URL.Path)
}
}))
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()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
if response.Code != http.StatusBadGateway || (networkID != "" &&
(deleteBody["runtime_id"] != missingRuntimeID || deleteBody["network_id"] != networkID)) {
t.Fatalf("invalid 201 did not reconcile its generation: status=%d delete=%v body=%s", response.Code, deleteBody, response.Body.String())
}
environment := store.bindings["account-a"]
if networkID != "" && environment.RuntimeCleanupPending {
t.Fatalf("known network cleanup did not converge: %#v", environment)
}
if networkID == "" && (!environment.RuntimeCleanupPending || environment.RuntimeCleanupRuntimeID != missingRuntimeID || environment.RuntimeCleanupNetworkID != "") {
t.Fatalf("unknown generation was not retained for manual reconciliation: %#v", environment)
}
})
}
}
func TestCreateBrowserRejectsActiveRuntimeWithoutNetworkGeneration(t *testing.T) {
requests := 0
gatewayServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
requests++
if request.Method != http.MethodPost {
t.Fatalf("active NULL network response triggered alias reconciliation: %s %s", request.Method, request.URL.Path)
}
response.WriteHeader(http.StatusCreated)
_, _ = response.Write([]byte(`{"id":"container-c1","network_id":""}`))
}))
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()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
environment := store.bindings["account-a"]
if response.Code != http.StatusBadGateway || requests != 1 || !environment.RuntimeCleanupPending ||
environment.RuntimeCleanupRuntimeID != missingRuntimeID || environment.RuntimeCleanupNetworkID != "" || environment.RuntimeID != "" {
t.Fatalf("active NULL network generation was activated: status=%d requests=%d environment=%#v body=%s",
response.Code, requests, environment, response.Body.String())
}
}
func TestCreateBrowserDoesNotDiscoverDisconnectedCreateByAlias(t *testing.T) {
for _, test := range []struct {
name string
reconcile bool
wantStatus int
}{
{name: "completed create", reconcile: true, wantStatus: http.StatusBadGateway},
{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", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true}})
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()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
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.actions) != 2 || store.actions[1].Outcome != "unknown" {
t.Fatalf("gateway result without an immutable generation must be unknown: %#v", store.actions)
}
mu.Lock()
wasCreated := created
mu.Unlock()
if !wasCreated || store.bindings["account-a"].RuntimeCleanupNetworkID != "" {
t.Fatalf("disconnected create lost its manual reconcile marker: %#v", store.bindings["account-a"])
}
})
}
}
func TestCreateBrowserDoesNotDiscoverBadGatewayCreateByAlias(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", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true}})
}))
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()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
if response.Code != http.StatusBadGateway {
t.Fatalf("expected unknown create, status=%d body=%s", response.Code, response.Body.String())
}
if _, err := store.GetEnv(context.Background(), "account-a"); err != nil {
t.Fatalf("reconciled create must retain DB state: err=%v", err)
}
mu.Lock()
gotCalls := listCalls
mu.Unlock()
if gotCalls != 0 {
t.Fatalf("unknown network generation was inspected by alias %d times", 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", BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: "network-id", ProxyReady: true},
},
}
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 view := byAlias["account-a"]; view.AccountID != "account-a" || view.NetworkExitHealth != "healthy" || view.ScheduleStatus != "ready" || view.ScheduleBlockReason != "" {
t.Fatalf("binding readiness must be exposed without credentials: %#v", view)
}
if byAlias["account-b"].State != "missing" {
t.Fatalf("env without container must report missing: %#v", byAlias["account-b"])
}
if runtime := store.bindings["account-a"].RuntimeID; runtime != "id-1" {
t.Fatalf("list reconciliation must heartbeat the running runtime, got %q", runtime)
}
}
func TestListRestoresProxyAfterGatewayRestartBeforeHeartbeat(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 3, Exit: store.exits["exit-1"],
}
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "container-id", Alias: "account-a", State: "running", BindingVersion: 3, NetworkExitID: "exit-1", NetworkID: "network-id", ProxyReady: false,
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusOK {
t.Fatalf("gateway restart recovery failed: %d %s", response.Code, response.Body.String())
}
if runtime := store.bindings["account-a"].RuntimeID; runtime != "container-id" {
t.Fatalf("runtime was activated before proxy recovery completed: %q", runtime)
}
requests := gateway.recorded()
if len(requests) != 2 || requests[0].path != "/v1/browsers" || requests[1].path != "/v1/browsers/account-a/proxy" {
t.Fatalf("expected list then proxy recovery without rebuild: %#v", requests)
}
if requests[1].body["binding_version"] != float64(3) || requests[1].body["runtime_id"] != "container-id" ||
requests[1].body["network_exit_id"] != "exit-1" {
t.Fatalf("proxy recovery did not use the current binding: %#v", requests[1].body)
}
}
func TestGatewayRestartRebuildsWhenOriginalProxyPortCannotBeRestored(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}}
store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"]}
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token", failProxy: true, containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", BindingVersion: 2, NetworkExitID: "exit-1", NetworkID: "network-old",
}}}
app := newTestApp(t, store, gateway)
if response := do(app, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusConflict {
t.Fatalf("lease-free running orphan was not fenced: %d %s", response.Code, response.Body.String())
}
requests := gateway.recorded()
if len(requests) != 3 || requests[1].path != "/v1/browsers/account-a/proxy" {
t.Fatalf("unproven orphan generation reached rebuild side effects: %#v", requests)
}
if runtime := store.bindings["account-a"].RuntimeID; runtime != "" {
t.Fatalf("unproven orphan generation was activated: %q", runtime)
}
}
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[len(requests)-2].method != http.MethodDelete || requests[len(requests)-2].path != "/v1/browsers/account-a" ||
requests[len(requests)-1].method != http.MethodPost || requests[len(requests)-1].path != "/v1/browsers" {
t.Fatalf("upgrade must delete then recreate: %#v", requests)
}
payload := requests[len(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)
}
if payload["binding_version"] != float64(2) || payload["network_exit_id"] != "exit-1" {
t.Fatalf("upgrade must create from the committed binding generation: %#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")
}
if len(store.actions) != 2 || store.actions[0].OldImageVersion != "148" ||
store.actions[1].NewImageVersion != "144.0.7559.132" || store.actions[1].Outcome != "succeeded" {
t.Fatalf("upgrade must emit image-aware audit evidence: %#v", store.actions)
}
}
func TestUpgradeBrowserUsesCommittedPostgresBinding(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
accountStore, err := phasea.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
if err := accountStore.Close(); err != nil {
t.Fatal(err)
}
store, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
accountStore, err = phasea.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
if err := accountStore.CreateAccount(ctx, phasea.Account{
ID: "account-a", Name: "账号 A", Platform: "douyin", PlatformAccountKey: "account-a",
Tags: []string{}, Cookies: "sessionid=value",
CredentialReference: phasea.CredentialReference{ID: "account-a-cookies", Provider: "os_keyring"},
CredentialKey: "creatorhub/account-a/cookies",
}, &testCredentialBridge{values: map[string]string{}}); err != nil {
t.Fatal(err)
}
if err := accountStore.Close(); err != nil {
t.Fatal(err)
}
gateway := &fakeGateway{token: "unit-test-gateway-token"}
gatewayServer := httptest.NewServer(gateway.handler(t))
t.Cleanup(gatewayServer.Close)
if _, err := store.CreateGateway(ctx, "gw-1", gatewayServer.URL, gateway.token); err != nil {
t.Fatal(err)
}
for _, image := range []hub.Image{
{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true},
{Version: "149", ImageRef: "registry.example/browser:149", Enabled: true},
} {
if err := store.CreateImage(ctx, image); err != nil {
t.Fatal(err)
}
}
exit, err := store.CreateNetworkExit(ctx, hub.NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080}, "")
if err != nil {
t.Fatal(err)
}
exit, _, err = store.RecordNetworkExitCheck(ctx, exit.ID, hub.ExitObservation{PublicIP: "203.0.113.1", Region: "test"}, "")
if err != nil {
t.Fatal(err)
}
before, created, err := store.CreateBoundEnv(ctx, hub.Env{
Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1},
}, "account-a", exit.ID)
if err != nil || !created {
t.Fatalf("create bound environment: created=%v err=%v", created, err)
}
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"149"}`)
if response.Code != http.StatusNoContent {
t.Fatalf("PostgreSQL-backed upgrade failed: %d: %s", response.Code, response.Body.String())
}
after, err := store.GetEnvironmentContext(ctx, "account-a")
if err != nil {
t.Fatal(err)
}
if after.ImageVersion != "149" || after.BindingVersion != before.BindingVersion+1 || after.RuntimeID != "" {
t.Fatalf("paused upgrade did not preserve the stopped-runtime contract: before=%#v after=%#v", before, after)
}
requests := gateway.recorded()
if len(requests) < 2 || requests[len(requests)-1].body["binding_version"] != float64(after.BindingVersion) ||
requests[len(requests)-1].body["network_exit_id"] != "" || requests[len(requests)-1].body["stopped"] != true {
t.Fatalf("gateway labels diverged from the committed binding: %#v", requests)
}
setFixtureAccountStatus(t, databaseURL, "active")
after, err = store.ActivateRuntime(ctx, "account-a", "stale-container", after.BindingVersion, after.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
gateway.mu.Lock()
gateway.failDelete = 1
gateway.containers = []containerStatus{{
ID: "stale-container", Alias: "account-a", State: "running", BindingVersion: after.BindingVersion,
NetworkExitID: "stale-exit", NetworkID: "network-old", ProxyReady: true,
}}
gateway.mu.Unlock()
response = do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusBadGateway {
t.Fatalf("PostgreSQL-backed reconcile delete failure returned %d: %s", response.Code, response.Body.String())
}
released, err := store.GetEnvironmentContext(ctx, "account-a")
if err != nil || released.RuntimeID != "" || released.BindingVersion != after.BindingVersion || !released.RuntimeCleanupPending {
t.Fatalf("delete failure must release the real Store lease without changing binding: %#v err=%v", released, err)
}
response = do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusOK {
t.Fatalf("PostgreSQL-backed cleanup retry returned %d: %s", response.Code, response.Body.String())
}
released, err = store.GetEnvironmentContext(ctx, "account-a")
if err != nil || released.RuntimeCleanupPending {
t.Fatalf("confirmed cleanup remained pending: %#v err=%v", released, err)
}
active, err := store.ActivateRuntime(ctx, "account-a", "coherent-container", released.BindingVersion, released.Exit.ID, "network-coherent")
if err != nil {
t.Fatal(err)
}
pending := runtimeCleanupGeneration(active, active.BindingVersion, active.RuntimeID, active.RuntimeNetworkID)
if err := store.SetRuntimeCleanupPending(ctx, pending, true); err != nil {
t.Fatal(err)
}
setFixtureAccountStatus(t, databaseURL, "paused")
gateway.mu.Lock()
gateway.failDelete = 1
gateway.containers = []containerStatus{{
ID: "coherent-container", Alias: "account-a", State: "running", BindingVersion: released.BindingVersion,
NetworkExitID: released.Exit.ID, NetworkID: "network-coherent", ProxyReady: true,
}}
gateway.mu.Unlock()
response = do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"`+released.Exit.ID+`"}`)
if response.Code != http.StatusBadGateway {
t.Fatalf("PostgreSQL-backed rebind delete failure returned %d: %s", response.Code, response.Body.String())
}
unchanged, err := store.GetEnvironmentContext(ctx, "account-a")
if err != nil || unchanged.BindingVersion != released.BindingVersion || unchanged.Exit.ID != released.Exit.ID || unchanged.RuntimeID != "" || !unchanged.RuntimeCleanupPending {
t.Fatalf("rebind committed before delete confirmation: %#v err=%v", unchanged, err)
}
}
type postgresRebindFixture struct {
store *hub.Store
db *sql.DB
databaseURL string
gateway *fakeGateway
bound hub.EnvironmentContext
exit hub.NetworkExit
}
type cleanupCommitUnknownStore struct {
hubStore
}
type failingRuntimeReleaseStore struct {
*hub.Store
err error
}
func (s failingRuntimeReleaseStore) ReleaseRuntime(context.Context, hub.EnvironmentContext) error {
return s.err
}
func setFixtureAccountStatus(t *testing.T, databaseURL, status string) {
t.Helper()
db, err := sql.Open("pgx", databaseURL)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`UPDATE social_account SET status = $1 WHERE id = 'account-a'`, status); err != nil {
t.Fatal(err)
}
}
func (s cleanupCommitUnknownStore) SetRuntimeCleanupPending(ctx context.Context, environment hub.EnvironmentContext, pending bool) error {
if err := s.hubStore.SetRuntimeCleanupPending(ctx, environment, pending); err != nil {
return err
}
return errors.New("cleanup commit result unknown")
}
type failContextRefreshStore struct {
hubStore
reads int
}
func (s *failContextRefreshStore) GetEnvironmentContext(ctx context.Context, alias string) (hub.EnvironmentContext, error) {
s.reads++
if s.reads >= 5 {
return hub.EnvironmentContext{}, errors.New("context refresh unavailable")
}
return s.hubStore.GetEnvironmentContext(ctx, alias)
}
func newPostgresRebindFixture(t *testing.T, databaseURL string) postgresRebindFixture {
t.Helper()
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
accountStore, err := phasea.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
if err := accountStore.Close(); err != nil {
t.Fatal(err)
}
store, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
accountStore, err = phasea.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
if err := accountStore.CreateAccount(ctx, phasea.Account{
ID: "account-a", Name: "账号 A", Platform: "douyin", PlatformAccountKey: "account-a",
Tags: []string{}, Cookies: "sessionid=value",
CredentialReference: phasea.CredentialReference{ID: "account-a-cookies", Provider: "os_keyring"},
CredentialKey: "creatorhub/account-a/cookies",
}, &testCredentialBridge{values: map[string]string{}}); err != nil {
t.Fatal(err)
}
if err := accountStore.Close(); err != nil {
t.Fatal(err)
}
gateway := &fakeGateway{token: "unit-test-gateway-token"}
gatewayServer := httptest.NewServer(gateway.handler(t))
t.Cleanup(gatewayServer.Close)
if _, err := store.CreateGateway(ctx, "gw-1", gatewayServer.URL, gateway.token); err != nil {
t.Fatal(err)
}
if err := store.CreateImage(ctx, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true}); err != nil {
t.Fatal(err)
}
exit, err := store.CreateNetworkExit(ctx, hub.NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080}, "")
if err != nil {
t.Fatal(err)
}
exit, _, err = store.RecordNetworkExitCheck(ctx, exit.ID, hub.ExitObservation{PublicIP: "203.0.113.1", Region: "test"}, "")
if err != nil {
t.Fatal(err)
}
bound, created, err := store.CreateBoundEnv(ctx, hub.Env{
Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1},
}, "account-a", exit.ID)
if err != nil || !created {
t.Fatalf("create bound environment: created=%v err=%v", created, err)
}
db, err := sql.Open("pgx", databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
return postgresRebindFixture{store: store, db: db, databaseURL: databaseURL, gateway: gateway, bound: bound, exit: exit}
}
func TestPauseClosesClaimGateBeforeStoppingRuntime(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
for _, test := range []struct {
name string
gatewayEndpoint string
wantStatus int
wantOutcome string
wantReason string
}{
{name: "confirmed stop", wantStatus: http.StatusNoContent, wantOutcome: "succeeded", wantReason: "environment_stopped"},
{name: "gateway unreachable", gatewayEndpoint: "http://127.0.0.1:1", wantStatus: http.StatusBadGateway, wantOutcome: "unknown", wantReason: "gateway_result_unknown"},
} {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
fixture := newPostgresRebindFixture(t, databaseURL)
accountStore, err := phasea.Open(ctx, fixture.databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = accountStore.Close() })
if err := accountStore.ResumeAccount(ctx, "account-a"); err != nil {
t.Fatal(err)
}
account, err := accountStore.GetAccount(ctx, "account-a")
if err != nil {
t.Fatal(err)
}
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "active-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-active")
if err != nil {
t.Fatal(err)
}
fixture.gateway.containers = []containerStatus{{
ID: "active-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID,
}}
if test.gatewayEndpoint != "" {
if _, err := fixture.db.ExecContext(ctx, `UPDATE gateway SET endpoint = $1 WHERE name = 'gw-1'`, test.gatewayEndpoint); err != nil {
t.Fatal(err)
}
}
if err := accountStore.CreateDraft(ctx, phasea.Draft{ID: "pause-draft", AccountID: "account-a", Version: 1, Content: "pause"}); err != nil {
t.Fatal(err)
}
if err := accountStore.Confirm(ctx, phasea.Confirmation{
ID: "pause-confirmation", AccountID: "account-a", AccountVersion: account.Version,
DraftID: "pause-draft", DraftVersion: 1, Version: 1,
}); err != nil {
t.Fatal(err)
}
if _, inserted, err := accountStore.Enqueue(ctx, phasea.Task{
ID: "pause-task", IdempotencyKey: "pause-idempotency", AccountID: "account-a", AccountVersion: account.Version,
DraftID: "pause-draft", DraftVersion: 1, ConfirmationID: "pause-confirmation", ConfirmationVersion: 1,
}); err != nil || !inserted {
t.Fatalf("enqueue pause task: inserted=%v err=%v", inserted, err)
}
if _, err := fixture.db.ExecContext(ctx, `
INSERT INTO execution_attempt (id, task_id) VALUES ('pause-attempt', 'pause-task');
UPDATE operation_task SET state = 'executing', current_attempt_id = 'pause-attempt',
lease_owner = 'worker-pause', lease_until = now() + interval '1 minute'
WHERE id = 'pause-task'`); err != nil {
t.Fatal(err)
}
app := fiber.New()
registerPhaseA(app, accountStore, fixture.store, nil)
response := do(app, http.MethodPost, "/api/phase-a/accounts/account-a/pause", "")
if response.Code != test.wantStatus {
t.Fatalf("pause returned %d, want %d: %s", response.Code, test.wantStatus, response.Body.String())
}
paused, err := accountStore.GetAccount(ctx, "account-a")
if err != nil || paused.RuntimeStatus != "paused" {
t.Fatalf("claim gate remained open: account=%#v err=%v", paused, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM operation_task WHERE id = 'pause-task' AND state = 'needs_confirmation'`, 1)
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM execution_attempt WHERE id = 'pause-attempt' AND outcome = 'uncertain'`, 1)
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0)
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM audit_event WHERE task_id = 'pause-task' AND attempt_id = 'pause-attempt'
AND browser_env_alias = 'account-a' AND network_exit_id IS NOT NULL AND runtime_instance_id IS NOT NULL
AND binding_version = 1 AND reason_code = 'account_paused'`, 1)
var outcome, reason string
if err := fixture.db.QueryRowContext(ctx, `SELECT outcome, reason_code FROM audit_event
WHERE account_id = 'account-a' AND action = 'stop' AND event_type = 'environment_action_finished'
ORDER BY id DESC LIMIT 1`).Scan(&outcome, &reason); err != nil || outcome != test.wantOutcome || reason != test.wantReason {
t.Fatalf("stop audit: outcome=%q reason=%q err=%v", outcome, reason, err)
}
})
}
}
func TestPauseAndRevokeStopContainerWithoutRuntimeLease(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
fixture := newPostgresRebindFixture(t, databaseURL)
accountStore, err := phasea.Open(ctx, fixture.databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = accountStore.Close() })
fixture.gateway.containers = []containerStatus{{
ID: "stopped-container", Alias: fixture.bound.Alias, State: "exited",
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID,
}}
app := fiber.New()
registerPhaseA(app, accountStore, fixture.store, nil)
if err := accountStore.ResumeAccount(ctx, fixture.bound.AccountID); err != nil {
t.Fatal(err)
}
if response := do(app, http.MethodPost, "/api/phase-a/accounts/account-a/pause", ""); response.Code != http.StatusNoContent {
t.Fatalf("pause stopped container without lease: %d %s", response.Code, response.Body.String())
}
if err := accountStore.ResumeAccount(ctx, fixture.bound.AccountID); err != nil {
t.Fatal(err)
}
fixture.gateway.mu.Lock()
fixture.gateway.containers[0].State = "running"
fixture.gateway.mu.Unlock()
if response := do(app, http.MethodPost, "/api/phase-a/accounts/account-a/revoke", ""); response.Code != http.StatusNoContent {
t.Fatalf("revoke running orphan without lease: %d %s", response.Code, response.Body.String())
}
after, err := fixture.store.GetEnvironmentContext(ctx, fixture.bound.Alias)
if err != nil || after.RuntimeID != "" || after.RuntimeCleanupPending {
t.Fatalf("lease-free stop did not converge: %#v err=%v", after, err)
}
account, err := accountStore.GetAccount(ctx, fixture.bound.AccountID)
if err != nil || account.AuthorizationStatus != "revoked" || account.RuntimeStatus != "paused" {
t.Fatalf("revoke gate did not remain closed: %#v err=%v", account, err)
}
fixture.gateway.mu.Lock()
container := fixture.gateway.containers[0]
fixture.gateway.mu.Unlock()
if container.State != "exited" {
t.Fatalf("running orphan was not stopped: %#v", container)
}
requests := fixture.gateway.recorded()
stopCalls := 0
for _, request := range requests {
if request.method == http.MethodPost && request.path == "/v1/browsers/account-a/stop" {
stopCalls++
if request.body["runtime_id"] != "stopped-container" || request.body["binding_version"] != float64(fixture.bound.BindingVersion) {
t.Fatalf("stop used the wrong discovered generation: %#v", request)
}
}
}
if stopCalls != 2 {
t.Fatalf("pause/revoke did not reconcile both lease-free containers: %#v", requests)
}
}
func installCleanupTransitionFailure(t *testing.T, ctx context.Context, db *sql.DB, condition string) {
t.Helper()
if _, err := db.ExecContext(ctx, `
CREATE FUNCTION fail_cleanup_transition() RETURNS trigger AS $$
BEGIN
IF `+condition+` THEN
RAISE EXCEPTION 'injected cleanup state failure';
END IF;
RETURN NEW;
END
$$ LANGUAGE plpgsql`); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, `
CREATE TRIGGER fail_cleanup_transition BEFORE UPDATE OF runtime_cleanup_pending ON environment_binding
FOR EACH ROW EXECUTE FUNCTION fail_cleanup_transition()`); err != nil {
t.Fatal(err)
}
}
func dropCleanupTransitionFailure(t *testing.T, ctx context.Context, db *sql.DB) {
t.Helper()
if _, err := db.ExecContext(ctx, `DROP TRIGGER fail_cleanup_transition ON environment_binding`); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, `DROP FUNCTION fail_cleanup_transition()`); err != nil {
t.Fatal(err)
}
}
func TestPostgresRebindRecoversRealConcurrentRaces(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
for _, test := range []struct {
name string
race string
state string
nullBinding bool
}{
{name: "stopped container after DELETE", race: "version", state: "exited"},
{name: "stopped NULL binding after DELETE", race: "version", state: "exited", nullBinding: true},
} {
t.Run(test.name, func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
var err error
if test.race == "upgrade" {
if err := fixture.store.CreateImage(ctx, hub.Image{Version: "149", ImageRef: "registry.example/browser:149", Enabled: true}); err != nil {
t.Fatal(err)
}
}
if test.nullBinding {
if _, err := fixture.db.ExecContext(ctx, `UPDATE environment_binding SET network_exit_id = NULL WHERE browser_env_alias = 'account-a'`); err != nil {
t.Fatal(err)
}
fixture.bound, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil {
t.Fatal(err)
}
}
if test.state == "running" {
setFixtureAccountStatus(t, fixture.databaseURL, "active")
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "old-container", fixture.bound.BindingVersion,
fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
pending := runtimeCleanupGeneration(fixture.bound, fixture.bound.BindingVersion, "old-container", "network-old")
if err := fixture.store.SetRuntimeCleanupPending(ctx, pending, true); err != nil {
t.Fatal(err)
}
fixture.bound, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil {
t.Fatal(err)
}
}
deleteDone, releaseDelete := make(chan struct{}), make(chan struct{})
fixture.gateway.deleteDone, fixture.gateway.releaseDelete = deleteDone, releaseDelete
fixture.gateway.containers = []containerStatus{{
ID: "old-container", Alias: "account-a", State: test.state, ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID,
}}
if test.state == "running" {
fixture.gateway.containers[0].NetworkID = "network-old"
}
app := fiber.New()
registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
server := httptest.NewServer(adaptor.FiberApp(app))
defer server.Close()
type result struct {
status int
body string
err error
}
done := make(chan result, 1)
go func() {
response, err := server.Client().Post(server.URL+"/api/browsers/account-a/rebind", "application/json",
strings.NewReader(`{"network_exit_id":"`+fixture.exit.ID+`"}`))
if err != nil {
done <- result{err: err}
return
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
done <- result{status: response.StatusCode, body: string(body)}
}()
select {
case <-deleteDone:
case <-time.After(5 * time.Second):
close(releaseDelete)
t.Fatal("rebind did not reach the DELETE barrier")
}
tx, err := fixture.db.BeginTx(ctx, nil)
upgradeBlocked := false
if err == nil {
switch test.race {
case "active":
_, err = tx.ExecContext(ctx, `
INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until)
VALUES ('runtime-race', 'account-a', 'account-a', 1, 'old-container', now() + interval '1 minute')`)
case "executing":
_, err = tx.ExecContext(ctx, `
INSERT INTO content_draft (id, account_id, version, content) VALUES ('draft-rebind', 'account-a', 1, 'test');
INSERT INTO operation_task (id, idempotency_key, account_id, account_version, draft_id, draft_version, state)
SELECT 'task-rebind', 'task-rebind', id, version, 'draft-rebind', 1, 'executing'
FROM social_account WHERE id = 'account-a'`)
case "version":
_, err = tx.ExecContext(ctx, `UPDATE environment_binding SET version = version + 1 WHERE browser_env_alias = 'account-a'`)
case "upgrade":
err = fixture.store.UpgradeEnv(ctx, "account-a", "149")
if errors.Is(err, hub.ErrConflict) {
upgradeBlocked, err = true, nil
}
}
}
if err == nil {
err = tx.Commit()
} else if tx != nil {
_ = tx.Rollback()
}
close(releaseDelete)
response := <-done
if err != nil {
t.Fatal(err)
}
expectedStatus := http.StatusConflict
if test.race == "upgrade" {
expectedStatus = http.StatusOK
}
if response.err != nil || response.status != expectedStatus {
t.Fatalf("rebind race returned status=%d body=%s err=%v", response.status, response.body, response.err)
}
if test.race == "upgrade" && !upgradeBlocked {
t.Fatal("upgrade advanced while runtime cleanup was pending")
}
after, err := fixture.store.GetEnvironmentContext(ctx, "account-a")
expectedVersion := fixture.bound.BindingVersion
if test.race == "version" || test.race == "upgrade" {
expectedVersion++
}
expectedImage := fixture.bound.ImageVersion
if err != nil || after.BindingVersion != expectedVersion || after.Exit.ID != fixture.bound.Exit.ID || after.ImageVersion != expectedImage {
t.Fatalf("failed rebind changed PostgreSQL binding: before=%#v after=%#v err=%v", fixture.bound, after, err)
}
fixture.gateway.mu.Lock()
containers := append([]containerStatus{}, fixture.gateway.containers...)
fixture.gateway.mu.Unlock()
if test.race == "version" {
if !after.RuntimeCleanupPending || after.RuntimeID != "" || len(containers) != 0 {
t.Fatalf("stale cleanup clear crossed the new binding generation: after=%#v containers=%#v", after, containers)
}
} else if after.RuntimeID != "" || len(containers) != 1 || containers[0].State == "running" || !containerMatchesBinding(containers[0], after) {
t.Fatalf("paused recovery did not preserve a stopped container without a lease: after=%#v containers=%#v", after, containers)
}
if test.race == "upgrade" {
requests := fixture.gateway.recorded()
var lastCreate map[string]any
for _, request := range requests {
if request.method == http.MethodPost && request.path == "/v1/browsers" {
lastCreate = request.body
}
}
if lastCreate["image"] != "registry.example/browser:148" || int64(lastCreate["binding_version"].(float64)) != after.BindingVersion {
t.Fatalf("blocked upgrade changed the rebind generation: %#v", lastCreate)
}
}
var outcome, reason string
if err := fixture.db.QueryRowContext(ctx, `
SELECT outcome, reason_code FROM audit_event
WHERE event_type = 'environment_action_finished' AND action = 'rebind'
ORDER BY id DESC LIMIT 1`).Scan(&outcome, &reason); err != nil {
t.Fatal(err)
}
expectedOutcome, expectedReason := "failed", "rebind_not_allowed"
if test.race == "upgrade" {
expectedOutcome, expectedReason = "succeeded", "environment_rebound"
} else if test.race == "version" {
expectedOutcome, expectedReason = "unknown", "gateway_result_unknown"
}
if outcome != expectedOutcome || reason != expectedReason {
t.Fatalf("recovered conflict audit mismatch: outcome=%s reason=%s", outcome, reason)
}
})
}
}
func TestPostgresCleanupPendingPersistsAndReconciles(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
for _, test := range []struct {
name string
cleanupPending int
disconnectDelete int
disconnectListAfterDelete bool
}{
{name: "accepted cleanup pending", cleanupPending: 2},
{name: "disconnect after accepted", cleanupPending: 1, disconnectDelete: 1, disconnectListAfterDelete: true},
} {
t.Run(test.name, func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
var err error
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "old-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
fixture.gateway.containers = []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID, NetworkID: "network-old",
}}
fixture.gateway.cleanupPending = test.cleanupPending
fixture.gateway.disconnectDelete = test.disconnectDelete
fixture.gateway.disconnectListAfterDelete = test.disconnectListAfterDelete
app := fiber.New()
registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
target, err := fixture.store.GetGateway(ctx, "gw-1")
if err != nil {
t.Fatal(err)
}
removed, cleanupErr := removeGatewayRuntime(ctx, fixture.store, target, fixture.bound)
if !removed || cleanupErr == nil {
t.Fatalf("non-final cleanup lost the removed fact: removed=%v err=%v", removed, cleanupErr)
}
after, err := fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || !after.RuntimeCleanupPending || after.BindingVersion != fixture.bound.BindingVersion ||
after.Exit.ID != fixture.bound.Exit.ID || after.RuntimeID != "" {
t.Fatalf("PostgreSQL did not persist cleanup pending atomically: %#v err=%v", after, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM environment_binding WHERE browser_env_alias = 'account-a' AND runtime_cleanup_pending`, 1)
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0)
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusOK {
t.Fatalf("list reconcile did not confirm cleanup: %d: %s", response.Code, response.Body.String())
}
after, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || after.RuntimeCleanupPending {
t.Fatalf("confirmed cleanup remained pending: %#v err=%v", after, err)
}
setFixtureAccountStatus(t, fixture.databaseURL, "paused")
response = do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"`+fixture.exit.ID+`"}`)
if response.Code != http.StatusOK {
t.Fatalf("rebind retry failed after cleanup confirmation: %d: %s", response.Code, response.Body.String())
}
after, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || after.RuntimeCleanupPending || after.BindingVersion != fixture.bound.BindingVersion+1 || after.RuntimeID != "" {
t.Fatalf("retry did not commit a consistent stopped generation: %#v err=%v", after, err)
}
})
}
}
func TestRemoveGatewayRuntimePersistsCleanupBeforeDelete(t *testing.T) {
newRuntime := func() (*memoryStore, *fakeGateway, hub.EnvironmentContext, hub.Gateway) {
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1", ImageVersion: "148"}
environment := hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container",
}
store.bindings[environment.Alias] = environment
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "old-container", Alias: environment.Alias, State: "running", ProxyReady: true,
BindingVersion: environment.BindingVersion, NetworkExitID: environment.Exit.ID,
}}}
server := httptest.NewServer(gateway.handler(t))
t.Cleanup(server.Close)
return store, gateway, environment, hub.Gateway{Endpoint: server.URL, Token: gateway.token}
}
t.Run("rollback keeps the lease and skips DELETE", func(t *testing.T) {
store, gateway, environment, target := newRuntime()
store.cleanupPendingErr = errors.New("injected rollback")
removed, err := removeGatewayRuntime(context.Background(), store, target, environment)
if err == nil || removed || len(gateway.recorded()) != 1 || gateway.recorded()[0].method != http.MethodGet {
t.Fatalf("failed pre-mark touched the gateway: removed=%v err=%v requests=%#v", removed, err, gateway.recorded())
}
after := store.bindings[environment.Alias]
if after.RuntimeCleanupPending || after.RuntimeID != environment.RuntimeID || len(gateway.containers) != 1 {
t.Fatalf("rollback did not preserve the active generation: after=%#v containers=%#v", after, gateway.containers)
}
})
t.Run("commit unknown remains retryable", func(t *testing.T) {
store, gateway, environment, target := newRuntime()
store.cleanupPendingErr = errors.New("commit result unknown")
store.cleanupPendingErrAfterMutation = true
removed, err := removeGatewayRuntime(context.Background(), store, target, environment)
if err == nil || removed || len(gateway.recorded()) != 1 || gateway.recorded()[0].method != http.MethodGet {
t.Fatalf("unknown pre-mark result touched the gateway: removed=%v err=%v requests=%#v", removed, err, gateway.recorded())
}
after := store.bindings[environment.Alias]
if !after.RuntimeCleanupPending || after.RuntimeID != "" || len(gateway.containers) != 1 {
t.Fatalf("committed pre-mark was not retryable: after=%#v containers=%#v", after, gateway.containers)
}
store.cleanupPendingErr = nil
store.cleanupPendingErrAfterMutation = false
removed, err = removeGatewayRuntime(context.Background(), store, target, after)
if err != nil || !removed || store.bindings[environment.Alias].RuntimeCleanupPending || len(gateway.containers) != 0 {
t.Fatalf("retry did not reconcile the committed pre-mark: removed=%v err=%v after=%#v containers=%#v",
removed, err, store.bindings[environment.Alias], gateway.containers)
}
})
for _, status := range []int{http.StatusNoContent, http.StatusNotFound} {
t.Run(fmt.Sprintf("clear rollback after %d", status), func(t *testing.T) {
store, gateway, environment, target := newRuntime()
environment = runtimeCleanupGeneration(environment, environment.BindingVersion, environment.RuntimeID)
if err := store.SetRuntimeCleanupPending(context.Background(), environment, true); err != nil {
t.Fatal(err)
}
environment = store.bindings[environment.Alias]
store.cleanupPendingErr = errors.New("injected clear rollback")
if status == http.StatusNotFound {
gateway.deleteNotFound = 1
}
removed, err := removeGatewayRuntime(context.Background(), store, target, environment)
if err == nil || !removed {
t.Fatalf("clear rollback lost the delete fact: removed=%v err=%v", removed, err)
}
after := store.bindings[environment.Alias]
if !after.RuntimeCleanupPending || after.RuntimeID != "" || len(gateway.containers) != 0 {
t.Fatalf("clear rollback was not retryable: after=%#v containers=%#v", after, gateway.containers)
}
store.cleanupPendingErr = nil
removed, err = removeGatewayRuntime(context.Background(), store, target, after)
if err != nil || !removed || store.bindings[environment.Alias].RuntimeCleanupPending {
t.Fatalf("clear retry failed: removed=%v err=%v after=%#v", removed, err, store.bindings[environment.Alias])
}
})
}
t.Run("empty runtime rejects a discovered different binding generation", func(t *testing.T) {
store, gateway, environment, target := newRuntime()
environment.RuntimeInstanceID, environment.RuntimeID = "", ""
store.bindings[environment.Alias] = environment
gateway.containers[0].BindingVersion = environment.BindingVersion + 1
removed, err := removeGatewayRuntime(context.Background(), store, target, environment)
if !errors.Is(err, hub.ErrConflict) || removed || len(gateway.recorded()) != 1 ||
store.bindings[environment.Alias].RuntimeCleanupPending {
t.Fatalf("mismatched discovered generation was adopted: removed=%v err=%v requests=%#v", removed, err, gateway.recorded())
}
})
t.Run("confirmed absence uses a non-empty cleanup generation", func(t *testing.T) {
store, gateway, environment, target := newRuntime()
environment.RuntimeInstanceID, environment.RuntimeID = "", ""
store.bindings[environment.Alias] = environment
gateway.containers = nil
removed, err := removeGatewayRuntime(context.Background(), store, target, environment)
if err != nil || !removed || store.bindings[environment.Alias].RuntimeCleanupPending {
t.Fatalf("absent runtime cleanup failed: removed=%v err=%v after=%#v", removed, err, store.bindings[environment.Alias])
}
requests := gateway.recorded()
if len(requests) == 0 || requests[len(requests)-1].method != http.MethodDelete ||
requests[len(requests)-1].body["runtime_id"] != missingRuntimeID {
t.Fatalf("absent runtime reused an empty generation: %#v", requests)
}
})
t.Run("known database runtime survives missing gateway container", func(t *testing.T) {
store, gateway, environment, target := newRuntime()
gateway.containers = nil
removed, err := removeGatewayRuntime(context.Background(), store, target, environment)
if err != nil || !removed {
t.Fatalf("known runtime cleanup failed: removed=%v err=%v", removed, err)
}
after := store.bindings[environment.Alias]
if after.RuntimeCleanupPending || after.RuntimeID != "" {
t.Fatalf("known runtime cleanup did not converge: %#v", after)
}
requests := gateway.recorded()
if len(requests) == 0 || requests[len(requests)-1].method != http.MethodDelete ||
requests[len(requests)-1].body["runtime_id"] != environment.RuntimeID {
t.Fatalf("known runtime id was replaced by an empty generation: %#v", requests)
}
})
t.Run("legacy pending requires manual reconcile before gateway mutation", func(t *testing.T) {
store, gateway, environment, target := newRuntime()
environment.RuntimeCleanupPending = true
store.bindings[environment.Alias] = environment
removed, err := removeGatewayRuntime(context.Background(), store, target, environment)
if !errors.Is(err, hub.ErrReconcileRequired) || removed || len(gateway.recorded()) != 0 {
t.Fatalf("legacy pending reached the gateway: removed=%v err=%v requests=%#v", removed, err, gateway.recorded())
}
})
}
func TestRestoreAndDiscardPreserveGenerationWhenCleanupMarkFails(t *testing.T) {
for _, test := range []struct {
name string
commitKnown bool
outcome string
reason string
}{
{name: "rollback", commitKnown: true, outcome: "failed", reason: "runtime_persistence_failed"},
{name: "commit unknown", outcome: "unknown", reason: "cleanup_result_unknown"},
} {
t.Run("restore "+test.name, func(t *testing.T) {
store := newMemoryStore()
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 2,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container", RuntimeNetworkID: "network-old",
}
store.cleanupPendingErr = errors.New("cleanup state unavailable")
store.cleanupPendingErrAfterMutation = !test.commitKnown
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 2, NetworkExitID: "stale-exit", NetworkID: "network-old",
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusInternalServerError {
t.Fatalf("failed cleanup mark returned %d: %s", response.Code, response.Body.String())
}
after := store.bindings["account-a"]
if test.commitKnown {
if after.RuntimeCleanupPending || after.RuntimeID != "old-container" {
t.Fatalf("rollback changed the old generation: %#v", after)
}
} else if !after.RuntimeCleanupPending || after.RuntimeID != "" {
t.Fatalf("commit-unknown lost its retry marker: %#v", after)
}
if len(gateway.containers) != 1 {
t.Fatalf("failed cleanup mark touched the old container: containers=%#v requests=%#v", gateway.containers, gateway.recorded())
}
for _, request := range gateway.recorded() {
if request.method != http.MethodGet {
t.Fatalf("failed cleanup mark reached a gateway side effect: %#v", gateway.recorded())
}
}
if len(store.actions) != 2 || store.actions[1].Outcome != test.outcome || store.actions[1].ReasonCode != test.reason {
t.Fatalf("restore audit mismatch: %#v", store.actions)
}
store.cleanupPendingErr = nil
store.cleanupPendingErrAfterMutation = false
if response = do(app, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusOK {
t.Fatalf("list retry failed: %d: %s", response.Code, response.Body.String())
}
if test.commitKnown {
after = store.bindings["account-a"]
} else {
if response = do(app, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent {
t.Fatalf("start retry failed: %d: %s", response.Code, response.Body.String())
}
after = store.bindings["account-a"]
}
if after.RuntimeCleanupPending || after.RuntimeID == "" || len(gateway.containers) != 1 || !containerMatchesBinding(gateway.containers[0], after) {
t.Fatalf("retry ended inconsistently: after=%#v containers=%#v", after, gateway.containers)
}
})
t.Run("discard "+test.name, func(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container",
}
store.cleanupPendingErr = errors.New("cleanup state unavailable")
store.cleanupPendingErrAfterMutation = !test.commitKnown
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 1, NetworkExitID: "exit-1",
}}}
app := newTestAppWithNetwork(t, store, gateway, fakeExitProbe{failure: "proxy_auth_failed"},
func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPost, "/api/browsers/account-a/start", "")
if response.Code != http.StatusInternalServerError {
t.Fatalf("failed discard mark returned %d: %s", response.Code, response.Body.String())
}
after := store.bindings["account-a"]
if test.commitKnown {
if after.RuntimeCleanupPending || after.RuntimeID != "old-container" {
t.Fatalf("discard rollback changed the old generation: %#v", after)
}
} else if !after.RuntimeCleanupPending || after.RuntimeID != "" {
t.Fatalf("discard commit-unknown lost its retry marker: %#v", after)
}
if len(gateway.containers) != 1 {
t.Fatalf("failed discard mark touched the gateway: containers=%#v requests=%#v", gateway.containers, gateway.recorded())
}
for _, request := range gateway.recorded() {
if request.method != http.MethodGet {
t.Fatalf("failed discard mark reached a gateway side effect: %#v", gateway.recorded())
}
}
if len(store.actions) != 2 || store.actions[1].Outcome != "unknown" || store.actions[1].ReasonCode != "cleanup_result_unknown" {
t.Fatalf("discard audit mismatch: %#v", store.actions)
}
store.cleanupPendingErr = nil
store.cleanupPendingErrAfterMutation = false
response = do(app, http.MethodPost, "/api/browsers/account-a/start", "")
if response.Code != http.StatusConflict {
t.Fatalf("discard retry returned %d: %s", response.Code, response.Body.String())
}
after = store.bindings["account-a"]
if after.RuntimeCleanupPending || after.RuntimeID != "" || len(gateway.containers) != 0 {
t.Fatalf("discard retry ended inconsistently: after=%#v containers=%#v", after, gateway.containers)
}
if len(store.actions) != 4 || store.actions[3].Outcome != "failed" || store.actions[3].ReasonCode != "proxy_auth_failed" {
t.Fatalf("discard retry audit mismatch: %#v", store.actions)
}
})
}
}
func TestExistingCreateStopsOnGatewayUnknownAndRetriesReuse(t *testing.T) {
store := newMemoryStore()
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container", RuntimeNetworkID: "network-old",
}
gateway := &fakeGateway{token: "unit-test-gateway-token", disconnectList: gatewayReconcileAttempts, containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: "network-old",
}}}
app := newTestApp(t, store, gateway)
body := `{"alias":"account-a","name":"甲","gateway":"gw-1","image_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"exit-1"}`
response := do(app, http.MethodPost, "/api/browsers", body)
if response.Code != http.StatusBadGateway {
t.Fatalf("gateway unknown create returned %d: %s", response.Code, response.Body.String())
}
if after := store.bindings["account-a"]; after.RuntimeID != "old-container" || after.RuntimeCleanupPending {
t.Fatalf("gateway unknown changed the existing lease: %#v", after)
}
for _, request := range gateway.recorded() {
if request.method == http.MethodPost {
t.Fatalf("gateway unknown created a duplicate container: %#v", gateway.recorded())
}
}
if len(store.actions) != 2 || store.actions[1].Outcome != "unknown" || store.actions[1].ReasonCode != "gateway_result_unknown" {
t.Fatalf("gateway unknown create audit mismatch: %#v", store.actions)
}
response = do(app, http.MethodPost, "/api/browsers", body)
if response.Code != http.StatusOK {
t.Fatalf("create retry did not reuse the runtime: %d: %s", response.Code, response.Body.String())
}
if after := store.bindings["account-a"]; after.RuntimeID != "old-container" || after.RuntimeCleanupPending {
t.Fatalf("create retry changed the matching generation: %#v", after)
}
if len(gateway.containers) != 1 || len(store.actions) != 4 || store.actions[3].Outcome != "succeeded" || store.actions[3].ReasonCode != "environment_reused" {
t.Fatalf("create retry ended inconsistently: containers=%#v actions=%#v", gateway.containers, store.actions)
}
}
func TestRemoveGatewayRuntimePreservesKnownNetworkGeneration(t *testing.T) {
for _, test := range []struct {
name string
observedNetwork string
wantConflict bool
}{
{name: "legacy gateway omits network", observedNetwork: ""},
{name: "replacement network conflicts", observedNetwork: "network-n2", wantConflict: true},
} {
t.Run(test.name, func(t *testing.T) {
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1"}
environment := hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active",
AuthorizationStatus: "authorized", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"],
RuntimeInstanceID: "runtime-instance", RuntimeID: "container-c1", RuntimeNetworkID: "network-n1"}
store.bindings["account-a"] = environment
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{ID: "container-c1", Alias: "account-a",
State: "running", BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: test.observedNetwork, ProxyReady: true}}}
server := httptest.NewServer(gateway.handler(t))
defer server.Close()
target := hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
_, err := removeGatewayRuntime(context.Background(), store, target, environment)
if test.wantConflict {
if !errors.Is(err, hub.ErrConflict) || len(gateway.recorded()) != 1 || store.bindings["account-a"].RuntimeID != "container-c1" {
t.Fatalf("replacement network was not fenced: err=%v requests=%#v environment=%#v", err, gateway.recorded(), store.bindings["account-a"])
}
return
}
requests := gateway.recorded()
if err != nil || len(requests) != 2 || requests[1].body["network_id"] != "network-n1" || store.bindings["account-a"].RuntimeCleanupPending {
t.Fatalf("known N1 was not preserved through cleanup: err=%v requests=%#v environment=%#v", err, requests, store.bindings["account-a"])
}
})
}
}
func TestGatewayLookupFailurePreservesLease(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}}
environment := hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container",
}
store.bindings[environment.Alias] = environment
store.gatewayFn = func(string) (hub.Gateway, error) { return hub.Gateway{}, errors.New("gateway lookup unavailable") }
if _, err := restoreOrRebuildRuntime(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil },
environment, containerStatus{ID: "old-container", Alias: environment.Alias, State: "running"}); err == nil {
t.Fatal("restore accepted an unknown gateway")
}
if err := discardRuntime(context.Background(), store, environment); err == nil {
t.Fatal("discard accepted an unknown gateway")
}
if after := store.bindings[environment.Alias]; after.RuntimeCleanupPending || after.RuntimeID != "old-container" {
t.Fatalf("gateway lookup failure changed the old generation: %#v", after)
}
}
func TestPostgresCleanupPendingTransactionRollbacks(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
t.Run("pre-mark rollback skips DELETE", func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
var err error
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "old-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
fixture.gateway.containers = []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID,
}}
installCleanupTransitionFailure(t, ctx, fixture.db, `NOT OLD.runtime_cleanup_pending AND NEW.runtime_cleanup_pending`)
target, err := fixture.store.GetGateway(ctx, "gw-1")
if err != nil {
t.Fatal(err)
}
removed, cleanupErr := removeGatewayRuntime(ctx, fixture.store, target, fixture.bound)
if cleanupErr == nil || removed || len(fixture.gateway.recorded()) != 1 || fixture.gateway.recorded()[0].method != http.MethodGet {
t.Fatalf("rolled-back pre-mark touched the gateway: removed=%v err=%v requests=%#v", removed, cleanupErr, fixture.gateway.recorded())
}
after, err := fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "old-container" || len(fixture.gateway.containers) != 1 {
t.Fatalf("PostgreSQL rollback did not preserve the active generation: after=%#v containers=%#v err=%v", after, fixture.gateway.containers, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1)
dropCleanupTransitionFailure(t, ctx, fixture.db)
fixture.gateway.cleanupPending = 2
removed, cleanupErr = removeGatewayRuntime(ctx, fixture.store, target, after)
if cleanupErr == nil || !removed {
t.Fatalf("202 retry did not retain pending cleanup: removed=%v err=%v", removed, cleanupErr)
}
after, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || !after.RuntimeCleanupPending || after.RuntimeID != "" || len(fixture.gateway.containers) != 0 {
t.Fatalf("202 retry was not durable: after=%#v containers=%#v err=%v", after, fixture.gateway.containers, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0)
})
t.Run("clear rollback remains pending and lifecycle retry recovers", func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
var err error
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "old-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
fixture.gateway.containers = []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID,
}}
fixture.gateway.deleteNotFound = 1
installCleanupTransitionFailure(t, ctx, fixture.db, `OLD.runtime_cleanup_pending AND NOT NEW.runtime_cleanup_pending`)
target, err := fixture.store.GetGateway(ctx, "gw-1")
if err != nil {
t.Fatal(err)
}
removed, cleanupErr := removeGatewayRuntime(ctx, fixture.store, target, fixture.bound)
if cleanupErr == nil || !removed {
t.Fatalf("404 clear rollback lost the delete fact: removed=%v err=%v", removed, cleanupErr)
}
after, err := fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || !after.RuntimeCleanupPending || after.RuntimeID != "" || len(fixture.gateway.containers) != 0 {
t.Fatalf("clear rollback was not durably pending: after=%#v containers=%#v err=%v", after, fixture.gateway.containers, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0)
dropCleanupTransitionFailure(t, ctx, fixture.db)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
app := fiber.New()
registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
if response := do(app, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusOK {
t.Fatalf("list retry did not confirm cleanup: %d: %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent {
t.Fatalf("lifecycle retry did not rebuild runtime: %d: %s", response.Code, response.Body.String())
}
after, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
fixture.gateway.mu.Lock()
containers := append([]containerStatus{}, fixture.gateway.containers...)
fixture.gateway.mu.Unlock()
if err != nil || after.RuntimeCleanupPending || after.RuntimeID == "" || len(containers) != 1 || !containerMatchesBinding(containers[0], after) {
t.Fatalf("lifecycle retry ended inconsistently: after=%#v containers=%#v err=%v", after, containers, err)
}
})
}
func TestPostgresLegacyCleanupPendingRequiresManualReconcile(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
fixture := newPostgresRebindFixture(t, databaseURL)
if _, err := fixture.db.ExecContext(ctx, `
UPDATE environment_binding SET runtime_cleanup_pending = true,
runtime_cleanup_binding_version = NULL, runtime_cleanup_instance_id = NULL, runtime_cleanup_runtime_id = NULL
WHERE id = $1`, fixture.bound.BindingID); err != nil {
t.Fatalf("write legacy pending state: %v", err)
}
environment, err := fixture.store.GetEnvironmentContext(ctx, fixture.bound.Alias)
if err != nil || !environment.RuntimeCleanupPending || environment.RuntimeCleanupBindingVersion != 0 || environment.RuntimeCleanupRuntimeID != "" {
t.Fatalf("read legacy pending state: %#v err=%v", environment, err)
}
target, err := fixture.store.GetGateway(ctx, environment.Gateway)
if err != nil {
t.Fatal(err)
}
removed, err := removeGatewayRuntime(ctx, fixture.store, target, environment)
if !errors.Is(err, hub.ErrReconcileRequired) || removed || len(fixture.gateway.recorded()) != 0 {
t.Fatalf("legacy pending reached gateway mutation: removed=%v err=%v requests=%#v", removed, err, fixture.gateway.recorded())
}
}
func TestPostgresCleanupMissingContainerReleasesKnownLease(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
fixture := newPostgresRebindFixture(t, databaseURL)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
environment, err := fixture.store.ActivateRuntime(ctx, fixture.bound.Alias, "known-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-known")
if err != nil {
t.Fatal(err)
}
target, err := fixture.store.GetGateway(ctx, environment.Gateway)
if err != nil {
t.Fatal(err)
}
removed, err := removeGatewayRuntime(ctx, fixture.store, target, environment)
if err != nil || !removed {
t.Fatalf("cleanup missing gateway container: removed=%v err=%v", removed, err)
}
after, err := fixture.store.GetEnvironmentContext(ctx, environment.Alias)
if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "" {
t.Fatalf("known PostgreSQL lease did not converge: %#v err=%v", after, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0)
requests := fixture.gateway.recorded()
if len(requests) == 0 || requests[len(requests)-1].method != http.MethodDelete ||
requests[len(requests)-1].body["runtime_id"] != "known-container" {
t.Fatalf("cleanup did not preserve the known runtime generation: %#v", requests)
}
}
func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
for _, test := range []struct {
name string
commitUnknown bool
outcome string
reason string
}{
{name: "rollback", outcome: "failed", reason: "runtime_persistence_failed"},
{name: "commit unknown", commitUnknown: true, outcome: "unknown", reason: "cleanup_result_unknown"},
} {
t.Run("restore "+test.name, func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
var err error
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "old-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
fixture.gateway.containers = []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: "stale-exit",
}}
var store hubStore = fixture.store
if test.commitUnknown {
store = cleanupCommitUnknownStore{hubStore: fixture.store}
} else {
installCleanupTransitionFailure(t, ctx, fixture.db, `NOT OLD.runtime_cleanup_pending AND NEW.runtime_cleanup_pending`)
}
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusInternalServerError {
t.Fatalf("restore cleanup failure returned %d: %s", response.Code, response.Body.String())
}
after, err := fixture.store.GetEnvironmentContext(ctx, "account-a")
if test.commitUnknown {
if err != nil || !after.RuntimeCleanupPending || after.RuntimeID != "" {
t.Fatalf("restore commit-unknown lost pending state: after=%#v err=%v", after, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0)
} else {
if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "old-container" {
t.Fatalf("restore rollback changed the old generation: after=%#v err=%v", after, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1)
}
if len(fixture.gateway.containers) != 1 {
t.Fatalf("restore cleanup failure touched the container: containers=%#v requests=%#v", fixture.gateway.containers, fixture.gateway.recorded())
}
for _, request := range fixture.gateway.recorded() {
if request.method != http.MethodGet {
t.Fatalf("restore cleanup failure reached a gateway side effect: %#v", fixture.gateway.recorded())
}
}
var outcome, reason string
if err := fixture.db.QueryRowContext(ctx, `
SELECT outcome, reason_code FROM audit_event WHERE action = 'reconcile'
ORDER BY id DESC LIMIT 1`).Scan(&outcome, &reason); err != nil {
t.Fatal(err)
}
if outcome != test.outcome || reason != test.reason {
t.Fatalf("restore audit mismatch: outcome=%s reason=%s", outcome, reason)
}
if !test.commitUnknown {
dropCleanupTransitionFailure(t, ctx, fixture.db)
}
retryApp := fiber.New()
registerHubWithNetwork(retryApp, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
if response = do(retryApp, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusOK {
t.Fatalf("restore list retry failed: %d: %s", response.Code, response.Body.String())
}
if test.commitUnknown {
if response = do(retryApp, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent {
t.Fatalf("restore start retry failed: %d: %s", response.Code, response.Body.String())
}
}
after, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
fixture.gateway.mu.Lock()
containers := append([]containerStatus{}, fixture.gateway.containers...)
fixture.gateway.mu.Unlock()
if err != nil || after.RuntimeCleanupPending || after.RuntimeID == "" || len(containers) != 1 || !containerMatchesBinding(containers[0], after) {
t.Fatalf("restore retry ended inconsistently: after=%#v containers=%#v err=%v", after, containers, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1)
})
t.Run("create restore "+test.name, func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
var err error
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "old-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
fixture.gateway.containers = []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: "stale-exit",
}}
var store hubStore = fixture.store
if test.commitUnknown {
store = cleanupCommitUnknownStore{hubStore: fixture.store}
} else {
installCleanupTransitionFailure(t, ctx, fixture.db, `NOT OLD.runtime_cleanup_pending AND NEW.runtime_cleanup_pending`)
}
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
body := `{"alias":"account-a","name":"甲","gateway":"gw-1","image_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"` + fixture.exit.ID + `"}`
response := do(app, http.MethodPost, "/api/browsers", body)
if response.Code != http.StatusInternalServerError {
t.Fatalf("create restore cleanup failure returned %d: %s", response.Code, response.Body.String())
}
after, err := fixture.store.GetEnvironmentContext(ctx, "account-a")
if test.commitUnknown {
if err != nil || !after.RuntimeCleanupPending || after.RuntimeID != "" {
t.Fatalf("create restore commit-unknown lost pending state: after=%#v err=%v", after, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0)
} else {
if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "old-container" {
t.Fatalf("create restore rollback changed the old generation: after=%#v err=%v", after, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1)
}
if len(fixture.gateway.containers) != 1 {
t.Fatalf("create restore cleanup failure touched the container: containers=%#v requests=%#v", fixture.gateway.containers, fixture.gateway.recorded())
}
for _, request := range fixture.gateway.recorded() {
if request.method != http.MethodGet {
t.Fatalf("create restore cleanup failure reached a gateway side effect: %#v", fixture.gateway.recorded())
}
}
var outcome, reason string
if err := fixture.db.QueryRowContext(ctx, `
SELECT outcome, reason_code FROM audit_event WHERE action = 'create'
ORDER BY id DESC LIMIT 1`).Scan(&outcome, &reason); err != nil {
t.Fatal(err)
}
if outcome != test.outcome || reason != test.reason {
t.Fatalf("create restore audit mismatch: outcome=%s reason=%s", outcome, reason)
}
if !test.commitUnknown {
dropCleanupTransitionFailure(t, ctx, fixture.db)
}
retryApp := fiber.New()
registerHubWithNetwork(retryApp, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response = do(retryApp, http.MethodPost, "/api/browsers", body)
if response.Code != http.StatusOK && response.Code != http.StatusCreated {
t.Fatalf("create restore retry failed: %d: %s", response.Code, response.Body.String())
}
after, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
fixture.gateway.mu.Lock()
containers := append([]containerStatus{}, fixture.gateway.containers...)
fixture.gateway.mu.Unlock()
if err != nil || after.RuntimeCleanupPending || after.RuntimeID == "" || len(containers) != 1 || !containerMatchesBinding(containers[0], after) {
t.Fatalf("create restore retry ended inconsistently: after=%#v containers=%#v err=%v", after, containers, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1)
})
t.Run("discard "+test.name, func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
var err error
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "old-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
fixture.gateway.containers = []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID, NetworkID: "network-old",
}}
var store hubStore = fixture.store
if test.commitUnknown {
store = cleanupCommitUnknownStore{hubStore: fixture.store}
} else {
installCleanupTransitionFailure(t, ctx, fixture.db, `NOT OLD.runtime_cleanup_pending AND NEW.runtime_cleanup_pending`)
}
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{failure: "proxy_auth_failed"}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPost, "/api/browsers/account-a/start", "")
if response.Code != http.StatusInternalServerError {
t.Fatalf("discard cleanup failure returned %d: %s", response.Code, response.Body.String())
}
after, err := fixture.store.GetEnvironmentContext(ctx, "account-a")
if test.commitUnknown {
if err != nil || !after.RuntimeCleanupPending || after.RuntimeID != "" {
t.Fatalf("discard commit-unknown lost pending state: after=%#v err=%v", after, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0)
} else {
if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "old-container" {
t.Fatalf("discard rollback changed the old generation: after=%#v err=%v", after, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1)
}
if len(fixture.gateway.containers) != 1 {
t.Fatalf("discard cleanup failure touched the container: containers=%#v requests=%#v", fixture.gateway.containers, fixture.gateway.recorded())
}
for _, request := range fixture.gateway.recorded() {
if request.method != http.MethodGet {
t.Fatalf("discard cleanup failure reached a gateway side effect: %#v", fixture.gateway.recorded())
}
}
var outcome, reason string
if err := fixture.db.QueryRowContext(ctx, `
SELECT outcome, reason_code FROM audit_event WHERE action = 'start'
ORDER BY id DESC LIMIT 1`).Scan(&outcome, &reason); err != nil {
t.Fatal(err)
}
if outcome != "unknown" || reason != "cleanup_result_unknown" {
t.Fatalf("discard audit mismatch: outcome=%s reason=%s", outcome, reason)
}
if !test.commitUnknown {
dropCleanupTransitionFailure(t, ctx, fixture.db)
}
retryApp := fiber.New()
registerHubWithNetwork(retryApp, fixture.store, fakeExitProbe{failure: "proxy_auth_failed"}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
if response = do(retryApp, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusOK {
t.Fatalf("discard retry returned %d: %s", response.Code, response.Body.String())
}
after, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "" {
t.Fatalf("discard retry ended inconsistently: after=%#v containers=%#v err=%v", after, fixture.gateway.containers, err)
}
for _, container := range fixture.gateway.containers {
if container.State == "running" {
t.Fatalf("discard retry retained a running container: %#v", fixture.gateway.containers)
}
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0)
})
}
}
func TestPostgresGatewayUnknownBlocksListAndCreateUntilRetry(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
for _, test := range []struct {
name string
set func(*fakeGateway, int)
}{
{name: "disconnect", set: func(gateway *fakeGateway, count int) { gateway.disconnectList = count }},
{name: "status 500", set: func(gateway *fakeGateway, count int) { gateway.failList = count }},
{name: "invalid JSON", set: func(gateway *fakeGateway, count int) { gateway.invalidList = count }},
} {
t.Run("list "+test.name, func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
var err error
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "old-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
fixture.gateway.containers = []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID, NetworkID: "network-old",
}}
test.set(fixture.gateway, 1)
app := fiber.New()
registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
if response := do(app, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusBadGateway {
t.Fatalf("gateway unknown list returned %d: %s", response.Code, response.Body.String())
}
after, err := fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "old-container" || len(fixture.gateway.containers) != 1 {
t.Fatalf("gateway unknown list changed the generation: after=%#v containers=%#v err=%v", after, fixture.gateway.containers, err)
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1)
if response := do(app, http.MethodGet, "/api/browsers", ""); response.Code != http.StatusOK {
t.Fatalf("gateway list retry failed: %d: %s", response.Code, response.Body.String())
}
after, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || after.RuntimeID != "old-container" || len(fixture.gateway.containers) != 1 {
t.Fatalf("gateway list retry ended inconsistently: after=%#v containers=%#v err=%v", after, fixture.gateway.containers, err)
}
})
t.Run("create "+test.name, func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
var err error
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "old-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
fixture.gateway.containers = []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID, NetworkID: "network-old",
}}
test.set(fixture.gateway, gatewayReconcileAttempts)
app := fiber.New()
registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
body := `{"alias":"account-a","name":"甲","gateway":"gw-1","image_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"` + fixture.exit.ID + `"}`
if response := do(app, http.MethodPost, "/api/browsers", body); response.Code != http.StatusBadGateway {
t.Fatalf("gateway unknown create returned %d: %s", response.Code, response.Body.String())
}
after, err := fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "old-container" || len(fixture.gateway.containers) != 1 {
t.Fatalf("gateway unknown create changed the generation: after=%#v containers=%#v err=%v", after, fixture.gateway.containers, err)
}
for _, request := range fixture.gateway.recorded() {
if request.method == http.MethodPost {
t.Fatalf("gateway unknown create sent a duplicate create: %#v", fixture.gateway.recorded())
}
}
var outcome, reason string
if err := fixture.db.QueryRowContext(ctx, `
SELECT outcome, reason_code FROM audit_event WHERE action = 'create'
ORDER BY id DESC LIMIT 1`).Scan(&outcome, &reason); err != nil {
t.Fatal(err)
}
if outcome != "unknown" || reason != "gateway_result_unknown" {
t.Fatalf("gateway unknown create audit mismatch: outcome=%s reason=%s", outcome, reason)
}
if response := do(app, http.MethodPost, "/api/browsers", body); response.Code != http.StatusOK {
t.Fatalf("gateway create retry failed: %d: %s", response.Code, response.Body.String())
}
after, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || after.RuntimeID != "old-container" || len(fixture.gateway.containers) != 1 {
t.Fatalf("gateway create retry ended inconsistently: after=%#v containers=%#v err=%v", after, fixture.gateway.containers, err)
}
if err := fixture.db.QueryRowContext(ctx, `
SELECT outcome, reason_code FROM audit_event WHERE action = 'create'
ORDER BY id DESC LIMIT 1`).Scan(&outcome, &reason); err != nil {
t.Fatal(err)
}
if outcome != "succeeded" || reason != "environment_reused" {
t.Fatalf("gateway create retry audit mismatch: outcome=%s reason=%s", outcome, reason)
}
})
}
}
func TestPostgresStrictGatewayListBlocksLifecycleUntilRetry(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
for _, test := range []struct {
name, method, path, action, successReason string
configure func(*fakeGateway, int)
active bool
wantStatus int
}{
{name: "list rejects top-level null", method: http.MethodGet, path: "/api/browsers", active: true, wantStatus: http.StatusOK,
configure: func(gateway *fakeGateway, count int) { gateway.invalidList, gateway.invalidListBody = count, `null` }},
{name: "create rejects null element", method: http.MethodPost, path: "/api/browsers", action: "create", active: true,
wantStatus: http.StatusOK, successReason: "environment_reused",
configure: func(gateway *fakeGateway, count int) { gateway.invalidList, gateway.invalidListBody = count, `[null]` }},
{name: "start rejects missing fields", method: http.MethodPost, path: "/api/browsers/account-a/start", action: "start", active: true,
wantStatus: http.StatusNoContent, successReason: "gateway_reconciled",
configure: func(gateway *fakeGateway, count int) {
gateway.invalidList, gateway.invalidListBody = count, `[{"alias":"account-a","state":"running"}]`
}},
{name: "rebind rejects partial body", method: http.MethodPost, path: "/api/browsers/account-a/rebind", action: "rebind",
wantStatus: http.StatusOK, successReason: "environment_rebound",
configure: func(gateway *fakeGateway, count int) { gateway.readErrorList = count }},
} {
t.Run(test.name, func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
if test.action == "start" || test.action == "" {
setFixtureAccountStatus(t, fixture.databaseURL, "active")
}
if test.active {
setFixtureAccountStatus(t, fixture.databaseURL, "active")
var err error
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "old-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
}
state, networkExitID, networkID := "running", fixture.bound.Exit.ID, "network-old"
if test.action == "rebind" {
state, networkExitID, networkID = "exited", "", ""
}
fixture.gateway.containers = []containerStatus{{
ID: "old-container", Alias: "account-a", State: state, ProxyReady: state == "running",
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: networkExitID, NetworkID: networkID,
}}
attempts := gatewayReconcileAttempts
if test.action == "" {
attempts = 1
}
test.configure(fixture.gateway, attempts)
app := fiber.New()
registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
body := ""
if test.action == "create" {
body = `{"alias":"account-a","name":"甲","gateway":"gw-1","image_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"` + fixture.exit.ID + `"}`
} else if test.action == "rebind" {
body = `{"network_exit_id":"` + fixture.exit.ID + `"}`
}
response := do(app, test.method, test.path, body)
if response.Code != http.StatusBadGateway {
t.Fatalf("strict list failure returned %d: %s", response.Code, response.Body.String())
}
after, err := fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || after.BindingVersion != fixture.bound.BindingVersion || after.Exit.ID != fixture.bound.Exit.ID ||
after.RuntimeCleanupPending || after.RuntimeID != fixture.bound.RuntimeID || len(fixture.gateway.containers) != 1 {
t.Fatalf("strict list failure changed the generation: before=%#v after=%#v containers=%#v err=%v",
fixture.bound, after, fixture.gateway.containers, err)
}
for _, request := range fixture.gateway.recorded() {
if request.method != http.MethodGet {
t.Fatalf("strict list failure advanced the lifecycle: %#v", fixture.gateway.recorded())
}
}
activeCount := 0
if test.active {
activeCount = 1
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, activeCount)
if test.action != "" {
var outcome, reason string
if err := fixture.db.QueryRowContext(ctx, `
SELECT outcome, reason_code FROM audit_event WHERE action = $1
ORDER BY id DESC LIMIT 1`, test.action).Scan(&outcome, &reason); err != nil {
t.Fatal(err)
}
if outcome != "unknown" || reason != "gateway_result_unknown" {
t.Fatalf("strict list audit mismatch: outcome=%s reason=%s", outcome, reason)
}
}
response = do(app, test.method, test.path, body)
if response.Code != test.wantStatus {
t.Fatalf("strict list retry returned %d: %s", response.Code, response.Body.String())
}
after, err = fixture.store.GetEnvironmentContext(ctx, "account-a")
fixture.gateway.mu.Lock()
containers := append([]containerStatus{}, fixture.gateway.containers...)
fixture.gateway.mu.Unlock()
wantVersion := fixture.bound.BindingVersion
if test.action == "rebind" {
wantVersion++
}
validRuntime := after.RuntimeID != "" && len(containers) == 1 && containerMatchesBinding(containers[0], after)
if test.action == "rebind" {
validRuntime = after.RuntimeID == "" && len(containers) == 0
}
if err != nil || after.BindingVersion != wantVersion || !validRuntime {
t.Fatalf("strict list retry ended inconsistently: after=%#v containers=%#v err=%v", after, containers, err)
}
activeAfter := 1
if test.action == "rebind" {
activeAfter = 0
}
assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, activeAfter)
if test.action != "" {
var outcome, reason string
if err := fixture.db.QueryRowContext(ctx, `
SELECT outcome, reason_code FROM audit_event WHERE action = $1
ORDER BY id DESC LIMIT 1`, test.action).Scan(&outcome, &reason); err != nil {
t.Fatal(err)
}
if outcome != "succeeded" || reason != test.successReason {
t.Fatalf("strict list retry audit mismatch: outcome=%s reason=%s", outcome, reason)
}
}
})
}
}
func isolatedControlPlaneDatabaseURL(t *testing.T, databaseURL string) string {
t.Helper()
admin, err := sql.Open("pgx", databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = admin.Close() })
schema := fmt.Sprintf("creatorhub_hh803_%d", time.Now().UnixNano())
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := admin.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
t.Errorf("drop test schema: %v", err)
}
})
parsed, err := url.Parse(databaseURL)
if err != nil {
t.Fatal(err)
}
query := parsed.Query()
query.Set("search_path", schema)
parsed.RawQuery = query.Encode()
return parsed.String()
}
func assertControlPlaneDatabaseCount(t *testing.T, db *sql.DB, query string, want int) {
t.Helper()
var got int
if err := db.QueryRow(query).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("query %q returned %d, want %d", query, got, want)
}
}
func TestUpgradeBrowserStopsBeforeCreateWhenPersistenceFails(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) == 0 || requests[len(requests)-1].method != http.MethodDelete || requests[len(requests)-1].path != "/v1/browsers/account-a" {
t.Fatalf("failed persistence must abort before creating the upgraded container: %#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 TestUpgradeStopsBeforeCreateWhenRuntimeReleaseFails(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.releaseErr = errors.New("database unavailable")
_ = 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.StatusInternalServerError {
t.Fatalf("expected release failure, got %d: %s", response.Code, response.Body.String())
}
requests := gateway.recorded()
for _, request := range requests {
if request.method == http.MethodDelete || request.method == http.MethodPost {
t.Fatalf("release failure must abort before delete: %#v", requests)
}
}
if len(store.actions) != 2 || store.actions[1].Outcome != "unknown" || store.actions[1].ReasonCode != "cleanup_result_unknown" {
t.Fatalf("release failure must be audited: %#v", store.actions)
}
}
func TestUpgradeRejectsInvalidVersionWithoutAuditingRawInput(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}}
gateway := &fakeGateway{token: "unit-test-gateway-token"}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers/account-a/upgrade", `{"version":"http://operator:secret@proxy.example"}`)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected invalid version rejection, got %d: %s", response.Code, response.Body.String())
}
if len(gateway.recorded()) != 0 || len(store.actions) != 2 {
t.Fatalf("invalid version must be rejected before gateway side effects: requests=%#v actions=%#v", gateway.recorded(), store.actions)
}
for _, action := range store.actions {
if action.NewImageVersion != "" || action.ReasonCode != "upgrade_input_rejected" {
t.Fatalf("raw invalid version reached audit: %#v", store.actions)
}
}
}
func TestPostgresRejectsInvalidLifecycleTargetsWithSanitizedAuditPairs(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
const secret = "http://operator:secret@proxy.example"
for _, test := range []struct {
action string
body string
reason string
}{
{action: "upgrade", body: `{"version":"` + secret + `"}`, reason: "upgrade_input_rejected"},
{action: "rebind", body: `{"network_exit_id":"` + secret + `"}`, reason: "rebind_input_rejected"},
} {
t.Run(test.action, func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
app := fiber.New()
registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPost, "/api/browsers/account-a/"+test.action, test.body)
if response.Code != http.StatusBadRequest {
t.Fatalf("invalid %s returned %d: %s", test.action, response.Code, response.Body.String())
}
var events, operations int
var reason string
if err := fixture.db.QueryRow(`
SELECT count(*), count(DISTINCT operation_id),
coalesce(max(reason_code) FILTER (WHERE event_type = 'environment_action_finished'), '')
FROM audit_event WHERE action = $1`, test.action).Scan(&events, &operations, &reason); err != nil {
t.Fatal(err)
}
if events != 2 || operations != 1 || reason != test.reason {
t.Fatalf("invalid %s audit mismatch: events=%d operations=%d reason=%q", test.action, events, operations, reason)
}
var leaked int
if err := fixture.db.QueryRow(`SELECT count(*) FROM audit_event WHERE row_to_json(audit_event)::text LIKE '%' || $1 || '%'`, secret).Scan(&leaked); err != nil {
t.Fatal(err)
}
if leaked != 0 {
t.Fatalf("invalid %s target leaked into audit", test.action)
}
})
}
}
func TestPostgresReconcileFinishedAuditUsesActivatedRuntime(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
fixture := newPostgresRebindFixture(t, databaseURL)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
var err error
fixture.bound, err = fixture.store.ActivateRuntime(context.Background(), "account-a", "stale-container",
fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
fixture.gateway.containers = []containerStatus{{
ID: "stale-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: "stale-exit", NetworkID: "network-old",
}}
app := fiber.New()
registerHubWithNetwork(app, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusOK {
t.Fatalf("reconcile returned %d: %s", response.Code, response.Body.String())
}
after, err := fixture.store.GetEnvironmentContext(context.Background(), "account-a")
if err != nil || after.RuntimeInstanceID == "" || after.RuntimeID != "container-id" {
t.Fatalf("reconcile did not activate replacement runtime: %#v err=%v", after, err)
}
var auditedRuntime string
var auditedBinding int64
if err := fixture.db.QueryRow(`
SELECT runtime_instance_id, binding_version FROM audit_event
WHERE event_type = 'environment_action_finished' AND action = 'reconcile'
ORDER BY id DESC LIMIT 1`).Scan(&auditedRuntime, &auditedBinding); err != nil {
t.Fatal(err)
}
if auditedRuntime != after.RuntimeInstanceID || auditedBinding != after.BindingVersion {
t.Fatalf("finished audit retained stale runtime: runtime=%q binding=%d current=%#v", auditedRuntime, auditedBinding, after)
}
}
func TestPostgresNonRunnableReconcileAuditsRuntimeRelease(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
for _, authorization := range []string{"authorized", "revoked"} {
for _, observation := range []string{"stopped", "missing"} {
for _, releaseFailure := range []bool{false, true} {
name := authorization + "/" + observation + "/success"
if releaseFailure {
name = authorization + "/" + observation + "/release failure"
}
t.Run(name, func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
setFixtureAccountStatus(t, fixture.databaseURL, "active")
var err error
fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "stopped-container",
fixture.bound.BindingVersion, fixture.bound.Exit.ID, "stopped-network")
if err != nil {
t.Fatal(err)
}
if _, err := fixture.db.ExecContext(ctx, `
UPDATE social_account SET status = 'paused', authorization_status = $1 WHERE id = 'account-a'`, authorization); err != nil {
t.Fatal(err)
}
if observation == "stopped" {
fixture.gateway.containers = []containerStatus{{
ID: "stopped-container", Alias: "account-a", State: "exited",
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID,
}}
}
var store hubStore = fixture.store
if releaseFailure {
store = failingRuntimeReleaseStore{Store: fixture.store, err: errors.New("release unavailable")}
}
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodGet, "/api/browsers", "")
wantStatus := http.StatusOK
wantOutcome, wantReason := "succeeded", "runtime_released"
if releaseFailure {
wantStatus, wantOutcome, wantReason = http.StatusInternalServerError, "failed", "runtime_release_failed"
}
if response.Code != wantStatus {
t.Fatalf("reconcile returned %d, want %d: %s", response.Code, wantStatus, response.Body.String())
}
after, err := fixture.store.GetEnvironmentContext(ctx, "account-a")
if err != nil || (after.RuntimeInstanceID != "") == !releaseFailure {
t.Fatalf("runtime lease after reconcile: %#v err=%v", after, err)
}
var events, operations, missingOperations int
var eventTypes, outcome, reason string
if err := fixture.db.QueryRowContext(ctx, `
SELECT count(*), count(DISTINCT operation_id),
count(*) FILTER (WHERE coalesce(operation_id, '') = ''),
string_agg(event_type, ',' ORDER BY id),
coalesce(max(outcome) FILTER (WHERE event_type = 'environment_action_finished'), ''),
coalesce(max(reason_code) FILTER (WHERE event_type = 'environment_action_finished'), '')
FROM audit_event WHERE action = 'reconcile'`).Scan(
&events, &operations, &missingOperations, &eventTypes, &outcome, &reason); err != nil {
t.Fatal(err)
}
if events != 2 || operations != 1 || missingOperations != 0 ||
eventTypes != "environment_action_requested,environment_action_finished" ||
outcome != wantOutcome || reason != wantReason {
t.Fatalf("reconcile audit mismatch: events=%d operations=%d missing=%d types=%q outcome=%q reason=%q",
events, operations, missingOperations, eventTypes, outcome, reason)
}
})
}
}
}
}
func TestPostgresImageDisableWaitsForEveryImageConsumer(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
for _, action := range []string{"create", "start", "reconcile", "rebind"} {
t.Run(action, func(t *testing.T) {
fixture := newPostgresRebindFixture(t, databaseURL)
if action == "start" || action == "reconcile" {
setFixtureAccountStatus(t, fixture.databaseURL, "active")
}
releaseLifecycle := make(chan struct{})
lifecycleStarted := make(chan struct{})
fixture.gateway.createStarted = lifecycleStarted
fixture.gateway.releaseCreate = releaseLifecycle
probe := networkExitProbe(fakeExitProbe{})
released := false
method, path, body, expected := http.MethodPost, "/api/browsers/account-a/"+action, "", http.StatusNoContent
switch action {
case "create":
if _, err := fixture.db.Exec(`DELETE FROM environment_binding; DELETE FROM browser_env`); err != nil {
t.Fatal(err)
}
path, expected = "/api/browsers", http.StatusCreated
body = fmt.Sprintf(`{"alias":"account-a","name":"甲","gateway":"gw-1","image_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"%s"}`, fixture.exit.ID)
case "reconcile":
method, path, expected = http.MethodGet, "/api/browsers", http.StatusOK
var err error
fixture.bound, err = fixture.store.ActivateRuntime(context.Background(), "account-a", "stale-container",
fixture.bound.BindingVersion, fixture.bound.Exit.ID, "network-old")
if err != nil {
t.Fatal(err)
}
fixture.gateway.containers = []containerStatus{{
ID: "stale-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: "stale-exit", NetworkID: "network-old",
}}
case "rebind":
expected = http.StatusConflict
body = `{"network_exit_id":"` + fixture.exit.ID + `"}`
fixture.gateway.createStarted = nil
fixture.gateway.releaseCreate = nil
probe = &blockingExitProbe{started: lifecycleStarted, release: releaseLifecycle}
fixture.gateway.containers = []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true,
BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID, NetworkID: "network-old",
}}
}
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()
})
registerHubWithNetwork(app, fixture.store, probe, func(hub.NetworkExitAccess) (string, error) { return "", nil })
server := httptest.NewServer(adaptor.FiberApp(app))
defer server.Close()
defer func() {
if !released {
close(releaseLifecycle)
}
}()
type result struct {
status int
err error
}
lifecycleDone := make(chan result, 1)
go func() {
request, err := http.NewRequest(method, server.URL+path, strings.NewReader(body))
if err == nil {
request.Header.Set("Content-Type", "application/json")
var response *http.Response
response, err = server.Client().Do(request)
if err == nil {
defer response.Body.Close()
lifecycleDone <- result{status: response.StatusCode}
return
}
}
lifecycleDone <- result{err: err}
}()
select {
case <-lifecycleStarted:
case result := <-lifecycleDone:
t.Fatalf("%s ended before lifecycle barrier: %#v", action, result)
case <-time.After(5 * time.Second):
t.Fatalf("%s did not reach lifecycle barrier", action)
}
disableDone := make(chan result, 1)
go func() {
request, err := http.NewRequest(http.MethodPut, server.URL+"/api/browser-images/148", strings.NewReader(
`{"image_ref":"registry.example/browser:148","enabled":false}`))
if err == nil {
request.Header.Set("Content-Type", "application/json")
var response *http.Response
response, err = server.Client().Do(request)
if err == nil {
defer response.Body.Close()
disableDone <- result{status: response.StatusCode}
return
}
}
disableDone <- result{err: err}
}()
<-disableArrived
select {
case result := <-disableDone:
t.Fatalf("image disable crossed in-flight %s: %#v", action, result)
case <-time.After(50 * time.Millisecond):
}
close(releaseLifecycle)
released = true
if result := <-lifecycleDone; result.err != nil || result.status != expected {
t.Fatalf("%s failed: %#v", action, result)
}
if result := <-disableDone; result.err != nil || result.status != http.StatusNoContent {
t.Fatalf("image disable after %s failed: %#v", action, result)
}
})
}
}
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()
})
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
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(5 * 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(time.Second):
}
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 TestListAndHeartbeatWaitForUpgradeCoordination(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: 1}}
_ = store.CreateImage(nil, hub.Image{Version: "149", ImageRef: "registry.example/browser:149", 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}
listArrived := make(chan struct{})
var listOnce sync.Once
app := fiber.New()
app.Use(func(c fiber.Ctx) error {
if c.Method() == http.MethodGet && c.Path() == "/api/browsers" {
listOnce.Do(func() { close(listArrived) })
}
return c.Next()
})
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
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":"149"}`))
if err != nil {
upgradeDone <- result{err: err}
return
}
defer response.Body.Close()
upgradeDone <- result{status: response.StatusCode}
}()
select {
case <-gateway.createStarted:
case <-time.After(5 * time.Second):
t.Fatal("upgrade did not reach gateway create")
}
listDone := make(chan result, 1)
go func() {
response, err := server.Client().Get(server.URL + "/api/browsers")
if err != nil {
listDone <- result{err: err}
return
}
defer response.Body.Close()
listDone <- result{status: response.StatusCode}
}()
<-listArrived
heartbeatDone := make(chan error, 1)
heartbeatStarted := make(chan struct{})
go func() {
close(heartbeatStarted)
heartbeatDone <- reconcileRuntimeLeases(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
}()
<-heartbeatStarted
select {
case result := <-listDone:
t.Fatalf("list reconciled a stale snapshot during upgrade: %#v", result)
case err := <-heartbeatDone:
t.Fatalf("heartbeat reconciled a stale snapshot during upgrade: %v", err)
case <-time.After(time.Second):
}
close(releaseCreate)
if result := <-upgradeDone; result.err != nil || result.status != http.StatusNoContent {
t.Fatalf("upgrade failed: %#v", result)
}
if result := <-listDone; result.err != nil || result.status != http.StatusOK {
t.Fatalf("coordinated list failed: %#v", result)
}
if err := <-heartbeatDone; err != nil {
t.Fatalf("coordinated heartbeat failed: %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}}
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
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/stop", ""); response.Code != http.StatusNoContent {
t.Fatalf("expected 204 for stop, 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)
}
if len(store.actions) != 4 || store.actions[0].Action != "start" || store.actions[1].Outcome != "succeeded" ||
store.actions[2].Action != "stop" || store.actions[3].Outcome != "succeeded" {
t.Fatalf("start and stop must each emit an audit pair: %#v", store.actions)
}
}
func TestStartRejectsPausedOrRevokedAccountBeforeGatewayCall(t *testing.T) {
for _, test := range []struct {
name, status, authorization string
}{
{name: "paused", status: "paused", authorization: "authorized"},
{name: "revoked", status: "paused", authorization: "revoked"},
} {
t.Run(test.name, func(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: test.status,
AuthorizationStatus: test.authorization, BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"],
}
gateway := &fakeGateway{token: "unit-test-gateway-token"}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers/account-a/start", "")
if response.Code != http.StatusConflict {
t.Fatalf("start returned %d: %s", response.Code, response.Body.String())
}
if len(gateway.recorded()) != 0 || len(store.actions) != 0 {
t.Fatalf("rejected start reached side effects: requests=%#v actions=%#v", gateway.recorded(), store.actions)
}
})
}
}
func TestReconcileStopsRuntimeForPausedOrRevokedAccount(t *testing.T) {
for _, authorization := range []string{"authorized", "revoked"} {
t.Run(authorization, func(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "paused", AuthorizationStatus: authorization,
BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"],
RuntimeInstanceID: "old-instance", RuntimeID: "old-container",
}
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 1, NetworkExitID: "exit-1",
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusOK {
t.Fatalf("reconcile returned %d: %s", response.Code, response.Body.String())
}
if runtime := store.bindings["account-a"].RuntimeID; runtime != "" {
t.Fatalf("non-runnable account retained runtime %q", runtime)
}
requests := gateway.recorded()
if len(requests) != 2 || requests[0].method != http.MethodGet || requests[1].method != http.MethodPost ||
requests[1].path != "/v1/browsers/account-a/stop" {
t.Fatalf("reconcile did not stop the runtime: %#v", requests)
}
})
}
}
func TestActivationConflictCleanupIsGenerationSafe(t *testing.T) {
for _, test := range []struct {
name string
containerID string
wantDeleted bool
}{
{name: "candidate remains current", containerID: "candidate-container", wantDeleted: true},
{name: "alias points to newer container", containerID: "new-container"},
} {
t.Run(test.name, func(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}}
environment := hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "paused", AuthorizationStatus: "authorized",
BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"],
}
store.bindings[environment.Alias] = environment
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: test.containerID, Alias: environment.Alias, State: "running", ProxyReady: true,
BindingVersion: environment.BindingVersion, NetworkExitID: environment.Exit.ID, NetworkID: "network-candidate",
}}}
server := httptest.NewServer(gateway.handler(t))
defer server.Close()
target := hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
_, err := activateGatewayRuntime(context.Background(), store, target, environment, "candidate-container", "network-candidate")
if err == nil {
t.Fatal("paused account unexpectedly activated the candidate")
}
deleted := len(gateway.containers) == 0
if deleted != test.wantDeleted {
t.Fatalf("generation-safe cleanup mismatch: deleted=%v requests=%#v", deleted, gateway.recorded())
}
if !test.wantDeleted {
for _, request := range gateway.recorded() {
if request.method == http.MethodDelete {
t.Fatalf("stale candidate deleted a newer alias generation: %#v", gateway.recorded())
}
}
}
})
}
}
func TestExplicitStopRejectsStaleBindingBeforeGatewaySideEffect(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized",
BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"], RuntimeInstanceID: "new-instance", RuntimeID: "new-container",
}
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "new-container", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 2, NetworkExitID: "exit-1",
}}}
server := httptest.NewServer(gateway.handler(t))
defer server.Close()
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
stale := store.bindings["account-a"]
stale.BindingVersion, stale.RuntimeInstanceID, stale.RuntimeID = 1, "old-instance", "old-container"
if err := stopEnvironmentRuntime(context.Background(), store, stale); !errors.Is(err, hub.ErrConflict) {
t.Fatalf("stale explicit stop was not rejected: %v", err)
}
if len(gateway.recorded()) != 0 || len(gateway.containers) != 1 || gateway.containers[0].ID != "new-container" {
t.Fatalf("stale explicit stop reached the gateway: requests=%#v containers=%#v", gateway.recorded(), gateway.containers)
}
}
func TestStopEnvironmentRuntimeReconcilesContainerWithoutLease(t *testing.T) {
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148"}
environment := hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "paused", AuthorizationStatus: "authorized",
BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"],
}
store.bindings[environment.Alias] = environment
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "orphan-container", Alias: environment.Alias, State: "running", BindingVersion: environment.BindingVersion,
}}}
server := httptest.NewServer(gateway.handler(t))
defer server.Close()
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
if err := stopEnvironmentRuntime(context.Background(), store, environment); err != nil {
t.Fatalf("stop orphan without lease: %v", err)
}
after := store.bindings[environment.Alias]
if after.RuntimeCleanupPending || after.RuntimeID != "" || gateway.containers[0].State != "exited" {
t.Fatalf("lease-free orphan stop did not converge: after=%#v containers=%#v", after, gateway.containers)
}
requests := gateway.recorded()
if len(requests) != 2 || requests[0].method != http.MethodGet || requests[1].method != http.MethodPost ||
requests[1].body["runtime_id"] != "orphan-container" || requests[1].body["binding_version"] != float64(environment.BindingVersion) {
t.Fatalf("stop did not use the discovered generation: %#v", requests)
}
}
func TestReconcileFinishedAuditUsesRebuiltRuntime(t *testing.T) {
store := newMemoryStore()
store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy-2.example", Port: 8080, HealthStatus: "healthy", Version: 1}
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized",
BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-2"], RuntimeInstanceID: "old-instance", RuntimeID: "old-container", RuntimeNetworkID: "network-old",
}
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 2, NetworkExitID: "exit-1", NetworkID: "network-old",
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusOK {
t.Fatalf("reconcile returned %d: %s", response.Code, response.Body.String())
}
if len(store.actions) != 2 || store.actions[1].Outcome != "succeeded" ||
store.actions[1].RuntimeInstanceID != "runtime-instance" || store.actions[1].BindingVersion != 2 || store.actions[1].NetworkExitID != "exit-2" {
t.Fatalf("finished audit used stale runtime context: %#v", store.actions)
}
}
func TestReconcileContextRefreshFailureClearsAllAuditCorrelation(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized",
BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"], RuntimeInstanceID: "old-instance", RuntimeID: "old-container", RuntimeNetworkID: "network-old",
}
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 2, NetworkExitID: "stale-exit", NetworkID: "network-old",
}}}
server := httptest.NewServer(gateway.handler(t))
defer server.Close()
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token}
failing := &failContextRefreshStore{hubStore: store}
app := fiber.New()
registerHubWithNetwork(app, failing, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusInternalServerError {
t.Fatalf("context refresh failure returned %d: %s", response.Code, response.Body.String())
}
finished := store.actions[len(store.actions)-1]
if finished.RuntimeInstanceID != "" || finished.BindingVersion != 0 || finished.NetworkExitID != "" {
t.Fatalf("failed context refresh retained partial stale correlation: %#v", finished)
}
}
func TestStoppedReconcileReportsRuntimeReleaseFailure(t *testing.T) {
gatewayServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodGet {
_ = json.NewEncoder(response).Encode([]containerStatus{{ID: "container-id", Alias: "account-a", State: "exited"}})
return
}
connection, _, err := response.(http.Hijacker).Hijack()
if err == nil {
_ = connection.Close()
}
}))
defer gatewayServer.Close()
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "container-id"}
store.releaseErr = errors.New("database unavailable")
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"}
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPost, "/api/browsers/account-a/stop", "")
if response.Code != http.StatusInternalServerError {
t.Fatalf("expected release failure, got %d: %s", response.Code, response.Body.String())
}
if len(store.actions) != 2 || store.actions[1].Outcome != "failed" || store.actions[1].ReasonCode != "runtime_release_failed" {
t.Fatalf("stopped reconcile release failure must be audited: %#v", store.actions)
}
}
func TestListReconcileAuditsRuntimeReleaseFailure(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "container-id",
}
store.releaseErr = errors.New("database unavailable")
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "container-id", Alias: "account-a", State: "exited", BindingVersion: 1, NetworkExitID: "exit-1",
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusInternalServerError {
t.Fatalf("expected release failure, got %d: %s", response.Code, response.Body.String())
}
if len(store.actions) != 2 || store.actions[0].Action != "reconcile" || store.actions[1].Outcome != "failed" ||
store.actions[1].ReasonCode != "runtime_release_failed" {
t.Fatalf("reconcile release failure must have its own audit pair: %#v", store.actions)
}
}
func TestRebindRebuildsRunningContainerWithLatestBinding(t *testing.T) {
store := newMemoryStore()
store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1}
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container",
}
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true,
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"exit-2"}`)
if response.Code != http.StatusOK {
t.Fatalf("running runtime rebind failed: %d: %s", response.Code, response.Body.String())
}
bound := store.bindings["account-a"]
if bound.Exit.ID != "exit-2" || bound.BindingVersion != 2 || bound.RuntimeID != "container-id" {
t.Fatalf("running runtime was not rebuilt on the latest binding: %#v", bound)
}
requests := gateway.recorded()
if len(requests) != 4 || requests[0].method != http.MethodGet || requests[2].method != http.MethodDelete || requests[3].method != http.MethodPost {
t.Fatalf("running rebind must inspect, delete and recreate: %#v", requests)
}
if requests[3].body["binding_version"] != float64(2) || requests[3].body["network_exit_id"] != "exit-2" {
t.Fatalf("recreated runtime did not carry the latest binding CAS: %#v", requests[3].body)
}
}
func TestSameExitRebindStillRebuildsRunningContainer(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "expired-runtime", RuntimeID: "old-container",
}
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true,
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"exit-1"}`)
if response.Code != http.StatusOK {
t.Fatalf("same-exit rebind failed: %d: %s", response.Code, response.Body.String())
}
bound := store.bindings["account-a"]
if bound.BindingVersion != 2 || bound.RuntimeID != "container-id" {
t.Fatalf("same-exit rebind did not rotate the binding generation and lease: %#v", bound)
}
requests := gateway.recorded()
if len(requests) != 4 || requests[0].method != http.MethodGet || requests[2].method != http.MethodDelete || requests[3].method != http.MethodPost {
t.Fatalf("same-exit rebind bypassed gateway reconciliation: %#v", requests)
}
if requests[3].body["binding_version"] != float64(2) || requests[3].body["network_exit_id"] != "exit-1" {
t.Fatalf("same-exit rebuild used stale metadata: %#v", requests[3].body)
}
}
func TestRuntimeReuseRechecksHealthAndDiscardsFailedExit(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "container-id",
}
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "container-id", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true,
}}}
app := newTestAppWithNetwork(t, store, gateway, fakeExitProbe{failure: "exit_auth_failed"},
func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusOK {
t.Fatalf("unhealthy runtime reconciliation failed: %d: %s", response.Code, response.Body.String())
}
if runtime := store.bindings["account-a"].RuntimeID; runtime != "" {
t.Fatalf("failed exit remained active: %q", runtime)
}
requests := gateway.recorded()
if len(requests) != 3 || requests[0].method != http.MethodGet || requests[2].method != http.MethodDelete {
t.Fatalf("failed exit must be rechecked and removed instead of renewed: %#v", requests)
}
}
func TestReconcileDeleteFailureReleasesLeaseAndAuditsUnknown(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 2,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "stale-container", RuntimeNetworkID: "network-old",
}
gateway := &fakeGateway{token: "unit-test-gateway-token", failDelete: 1, containers: []containerStatus{{
ID: "stale-container", Alias: "account-a", State: "running", BindingVersion: 2, NetworkExitID: "stale-exit", NetworkID: "network-old", ProxyReady: true,
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusBadGateway {
t.Fatalf("delete failure must remain unknown, got %d: %s", response.Code, response.Body.String())
}
if runtime := store.bindings["account-a"].RuntimeID; runtime != "" {
t.Fatalf("delete failure retained DB runtime lease: %q", runtime)
}
if len(store.actions) != 2 || store.actions[0].Action != "reconcile" || store.actions[1].Outcome != "unknown" ||
store.actions[1].ReasonCode != "gateway_result_unknown" {
t.Fatalf("delete failure was not audited as retryable unknown: %#v", store.actions)
}
if len(gateway.containers) != 1 {
t.Fatal("failed delete unexpectedly removed the gateway container")
}
}
func TestDisableExitImmediatelyDiscardsRuntime(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "container-id",
}
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "container-id", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true,
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/network-exits/exit-1/disable", "")
if response.Code != http.StatusOK {
t.Fatalf("disable cleanup failed: %d: %s", response.Code, response.Body.String())
}
if runtime := store.bindings["account-a"].RuntimeID; runtime != "" {
t.Fatalf("disabled exit remained active: %q", runtime)
}
requests := gateway.recorded()
if len(requests) != 3 || requests[0].method != http.MethodGet || requests[2].method != http.MethodDelete {
t.Fatalf("disable must reconcile and remove the runtime: %#v", requests)
}
}
func TestDisableExitWaitsForInFlightBrowserLifecycle(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: 1}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"],
}
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", 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}
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
server := httptest.NewServer(adaptor.FiberApp(app))
defer server.Close()
statuses := make(chan int, 2)
go func() {
response, err := server.Client().Post(server.URL+"/api/browsers/account-a/start", "application/json", nil)
if err != nil {
statuses <- 0
return
}
defer response.Body.Close()
statuses <- response.StatusCode
}()
select {
case <-gateway.createStarted:
case <-time.After(time.Second):
t.Fatal("start did not reach gateway create")
}
go func() {
response, err := server.Client().Post(server.URL+"/api/network-exits/exit-1/disable", "application/json", nil)
if err != nil {
statuses <- 0
return
}
defer response.Body.Close()
statuses <- response.StatusCode
}()
select {
case status := <-statuses:
t.Fatalf("lifecycle operation completed before the running create: %d", status)
case <-time.After(50 * time.Millisecond):
}
close(releaseCreate)
for range 2 {
if status := <-statuses; status != http.StatusNoContent && status != http.StatusOK {
t.Fatalf("unexpected lifecycle status: %d", status)
}
}
if store.exits["exit-1"].HealthStatus != "disabled" || store.bindings["account-a"].RuntimeID != "" {
t.Fatalf("disable did not clean the completed runtime: exit=%#v binding=%#v", store.exits["exit-1"], store.bindings["account-a"])
}
}
func TestDisableExitPropagatesUnknownGatewayReadAndPreservesLease(t *testing.T) {
for _, test := range []struct {
name string
handler http.HandlerFunc
}{
{name: "disconnect", handler: func(response http.ResponseWriter, _ *http.Request) {
connection, _, err := response.(http.Hijacker).Hijack()
if err == nil {
_ = connection.Close()
}
}},
{name: "status 500", handler: func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusInternalServerError)
_, _ = response.Write([]byte(`{"error":"docker unavailable"}`))
}},
{name: "invalid JSON", handler: func(response http.ResponseWriter, _ *http.Request) {
_, _ = response.Write([]byte(`{"not":"a browser list"}`))
}},
} {
t.Run(test.name, func(t *testing.T) {
gatewayServer := httptest.NewServer(test.handler)
defer gatewayServer.Close()
store := newMemoryStore()
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "container-id",
}
store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"}
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
response := do(app, http.MethodPost, "/api/network-exits/exit-1/disable", "")
if response.Code != http.StatusBadGateway {
t.Fatalf("gateway unknown must not return 200: %d: %s", response.Code, response.Body.String())
}
if runtime := store.bindings["account-a"].RuntimeID; runtime != "container-id" {
t.Fatalf("gateway unknown changed the unconfirmed runtime lease: %q", runtime)
}
if store.exits["exit-1"].HealthStatus != "disabled" {
t.Fatal("disable state was not retained for retryable reconciliation")
}
})
}
}
func TestDirectBindingIsListable(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, ProxyServer: "http://legacy:secret@proxy.example:8080", DisableNonProxiedUDP: true,
}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
RuntimeInstanceID: "direct-runtime", RuntimeID: "direct-container", RuntimeNetworkID: "direct-network",
}
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "direct-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkID: "direct-network", ProxyReady: true,
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodGet, "/api/browsers", "")
if response.Code != http.StatusOK {
t.Fatalf("direct binding broke browser listing: %d: %s", response.Code, response.Body.String())
}
var views []envView
if err := json.Unmarshal(response.Body.Bytes(), &views); err != nil || len(views) != 1 || views[0].RecoveryRequired || views[0].NetworkExitID != "" || views[0].ScheduleStatus != "ready" {
t.Fatalf("direct runtime state was not visible: %#v err=%v", views, err)
}
}
func TestRebindDeleteFailureKeepsOriginalBinding(t *testing.T) {
for _, test := range []struct {
name string
binding hub.EnvironmentContext
targetID string
}{
{name: "running binding", binding: hub.EnvironmentContext{
AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: hub.NetworkExit{ID: "exit-1", Protocol: "socks5", Host: "127.0.0.1", Port: 1080, HealthStatus: "healthy"},
RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container",
}, targetID: "exit-2"},
{name: "legacy NULL binding", binding: hub.EnvironmentContext{
AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
RuntimeInstanceID: "legacy-runtime", RuntimeID: "legacy-container",
}, targetID: "exit-1"},
} {
t.Run(test.name, func(t *testing.T) {
store := newMemoryStore()
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1}
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
before := test.binding
before.Env = store.envs["account-a"]
store.bindings["account-a"] = before
gateway := &fakeGateway{token: "unit-test-gateway-token", failDelete: 1, containers: []containerStatus{{
ID: before.RuntimeID, Alias: "account-a", State: "running", BindingVersion: before.BindingVersion, NetworkExitID: before.Exit.ID, ProxyReady: true,
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"`+test.targetID+`"}`)
if response.Code != http.StatusBadGateway {
t.Fatalf("delete failure must remain unknown: %d: %s", response.Code, response.Body.String())
}
after := store.bindings["account-a"]
if after.BindingVersion != before.BindingVersion || after.Exit.ID != before.Exit.ID || !after.RuntimeCleanupPending || after.RuntimeID != "" {
t.Fatalf("binding committed before old container deletion: before=%#v after=%#v", before, after)
}
if len(gateway.containers) != 1 {
t.Fatalf("non-final delete result lost the existing container: %#v", gateway.containers)
}
if len(store.actions) != 2 || store.actions[1].Outcome != "unknown" || store.actions[1].ReasonCode != "gateway_result_unknown" {
t.Fatalf("delete failure was not audited as unknown: %#v", store.actions)
}
})
}
}
func TestRebindNetworkCleanupPendingBlocksUntilConfirmed(t *testing.T) {
for _, test := range []struct {
name string
cleanupPending int
disconnectDelete int
disconnectListAfterDelete bool
}{
{name: "cleanup pending", cleanupPending: 2},
{name: "disconnect after accepted", cleanupPending: 1, disconnectDelete: 1, disconnectListAfterDelete: true},
} {
t.Run(test.name, func(t *testing.T) {
store := newMemoryStore()
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1}
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container",
}
gateway := &fakeGateway{token: "unit-test-gateway-token", cleanupPending: test.cleanupPending,
disconnectDelete: test.disconnectDelete, disconnectListAfterDelete: test.disconnectListAfterDelete,
containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true,
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"exit-2"}`)
if response.Code != http.StatusBadGateway {
t.Fatalf("network cleanup uncertainty returned %d: %s", response.Code, response.Body.String())
}
after := store.bindings["account-a"]
if after.BindingVersion != 1 || after.Exit.ID != "exit-1" || !after.RuntimeCleanupPending || after.RuntimeID != "" {
t.Fatalf("network cleanup uncertainty changed the binding: %#v", after)
}
if len(store.actions) != 2 || store.actions[1].Outcome != "unknown" || store.actions[1].ReasonCode != "gateway_result_unknown" {
t.Fatalf("network cleanup uncertainty audit mismatch: %#v", store.actions)
}
if len(gateway.containers) != 0 {
t.Fatalf("container-removed fact was lost: %#v", gateway.containers)
}
response = do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"exit-2"}`)
if response.Code != http.StatusOK {
t.Fatalf("confirmed cleanup did not allow retry: %d: %s", response.Code, response.Body.String())
}
after = store.bindings["account-a"]
if after.RuntimeCleanupPending || after.BindingVersion != 2 || after.Exit.ID != "exit-2" || after.RuntimeID != "" {
t.Fatalf("retry did not commit a clean stopped generation: %#v", after)
}
})
}
}
func TestCleanupPendingBlocksEveryLifecyclePath(t *testing.T) {
for _, test := range []struct {
name, method, path, body string
}{
{name: "list reconcile", method: http.MethodGet, path: "/api/browsers"},
{name: "create reuse", method: http.MethodPost, path: "/api/browsers", body: `{"alias":"account-a","name":"甲","gateway":"gw-1","image_version":"148","fingerprint":{"seed":1},"account_id":"account-a","network_exit_id":"exit-1"}`},
{name: "start", method: http.MethodPost, path: "/api/browsers/account-a/start"},
{name: "upgrade", method: http.MethodPost, path: "/api/browsers/account-a/upgrade", body: `{"version":"149"}`},
{name: "rebind", method: http.MethodPost, path: "/api/browsers/account-a/rebind", body: `{"network_exit_id":"exit-1"}`},
{name: "recycle", method: http.MethodDelete, path: "/api/browsers/account-a"},
} {
t.Run(test.name, func(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}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
RuntimeCleanupPending: true, RuntimeCleanupBindingVersion: 1,
RuntimeCleanupRuntimeID: missingRuntimeID, RuntimeCleanupNetworkID: "network-old", Exit: store.exits["exit-1"],
}
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
_ = store.CreateImage(nil, hub.Image{Version: "149", ImageRef: "registry.example/browser:149", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token", cleanupPending: 2}
app := newTestApp(t, store, gateway)
response := do(app, test.method, test.path, test.body)
if response.Code != http.StatusBadGateway {
t.Fatalf("pending cleanup returned %d: %s", response.Code, response.Body.String())
}
after := store.bindings["account-a"]
if !after.RuntimeCleanupPending || after.BindingVersion != 1 || after.Exit.ID != "exit-1" || after.RuntimeID != "" {
t.Fatalf("lifecycle path advanced a pending generation: %#v", after)
}
for _, request := range gateway.recorded() {
if request.method == http.MethodPost && request.path == "/v1/browsers" {
t.Fatalf("lifecycle path created before cleanup confirmation: %#v", gateway.recorded())
}
}
})
}
}
func TestRebindPreparesRunningRuntimeBeforeDelete(t *testing.T) {
for _, test := range []struct {
name string
withImage bool
credential bool
}{
{name: "image unavailable"},
{name: "credential unavailable", withImage: true, credential: true},
} {
t.Run(test.name, func(t *testing.T) {
store := newMemoryStore()
if test.withImage {
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
}
store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1}
if test.credential {
store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1,
CredentialReference: &hub.CredentialReference{ID: "credential-exit", Provider: "os_keyring"}}
}
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a",
BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container"}
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true,
}}}
app := newTestAppWithNetwork(t, store, gateway, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) {
return "", errors.New("credential unavailable")
})
response := do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"exit-2"}`)
if response.Code < 400 || len(gateway.recorded()) != 1 || gateway.recorded()[0].method != http.MethodGet {
t.Fatalf("runtime preparation failure touched the old container: status=%d requests=%#v", response.Code, gateway.recorded())
}
if after := store.bindings["account-a"]; after.BindingVersion != 1 || after.Exit.ID != "exit-1" || after.RuntimeID != "old-container" {
t.Fatalf("runtime preparation failure changed state: %#v", after)
}
})
}
}
func TestRebindCandidateUnknownCreateLeavesGenerationPending(t *testing.T) {
store := newMemoryStore()
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1}
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a",
BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "old-container"}
gateway := &fakeGateway{token: "unit-test-gateway-token", failCreate: 1, containers: []containerStatus{{
ID: "old-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true,
}}}
app := newTestApp(t, store, gateway)
response := do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"exit-2"}`)
if response.Code != http.StatusConflict {
t.Fatalf("candidate create failure returned %d: %s", response.Code, response.Body.String())
}
after := store.bindings["account-a"]
if after.BindingVersion != 1 || after.Exit.ID != "exit-1" || after.RuntimeID != "" || !after.RuntimeCleanupPending ||
after.RuntimeCleanupBindingVersion != 2 || after.RuntimeCleanupRuntimeID != missingRuntimeID || after.RuntimeCleanupNetworkID != "" ||
len(gateway.containers) != 0 {
t.Fatalf("candidate create unknown result was not retained for manual reconcile: after=%#v containers=%#v", after, gateway.containers)
}
if len(store.actions) != 2 || store.actions[1].Outcome != "unknown" || store.actions[1].ReasonCode != "gateway_result_unknown" {
t.Fatalf("candidate create failure audit mismatch: %#v", store.actions)
}
}
func TestExistingEnvironmentCleanupNeverReturnsReusedSuccess(t *testing.T) {
for _, test := range []struct {
name string
probe *sequenceExitProbe
resolve func(hub.NetworkExitAccess) (string, error)
}{
{name: "second probe fails", probe: &sequenceExitProbe{failures: []string{"", "exit_auth_failed"}},
resolve: func(hub.NetworkExitAccess) (string, error) { return "username:password", nil }},
{name: "second credential restore fails", probe: &sequenceExitProbe{}, resolve: func() func(hub.NetworkExitAccess) (string, error) {
calls := 0
return func(hub.NetworkExitAccess) (string, error) {
calls++
if calls == 2 {
return "", errors.New("credential unavailable")
}
return "username:password", nil
}
}()},
} {
t.Run(test.name, func(t *testing.T) {
store := newMemoryStore()
store.exits["exit-1"] = hub.NetworkExit{
ID: "exit-1", Protocol: "socks5", Host: "127.0.0.1", Port: 1080, HealthStatus: "healthy", Version: 1,
CredentialReference: &hub.CredentialReference{ID: "credential-exit", Provider: "os_keyring"},
}
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", ImageVersion: "148.0.7778.215", Fingerprint: hub.Fingerprint{Seed: 2024, Platform: "windows", Timezone: "Asia/Shanghai"}}
store.bindings["account-a"] = hub.EnvironmentContext{
Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1,
Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "container-id",
}
_ = store.CreateImage(nil, hub.Image{Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{
ID: "container-id", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1",
}}}
app := newTestAppWithNetwork(t, store, gateway, test.probe, test.resolve)
response := do(app, http.MethodPost, "/api/browsers", createEnvBody)
if response.Code != http.StatusConflict {
t.Fatalf("cleaned runtime must not return reused success: %d: %s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "not running") {
t.Fatalf("cleaned runtime response did not state the stopped result: %s", response.Body.String())
}
if runtime := store.bindings["account-a"].RuntimeID; runtime != "" {
t.Fatalf("failed recovery retained runtime: %q probe_calls=%d actions=%#v requests=%#v", runtime, test.probe.calls, store.actions, gateway.recorded())
}
if len(store.actions) != 2 || store.actions[1].Outcome != "failed" || store.actions[1].ReasonCode != "runtime_unavailable" {
t.Fatalf("cleaned runtime was audited as reused success: %#v", store.actions)
}
})
}
}
func TestStartRebuildsStoppedContainerAfterRebind(t *testing.T) {
store := newMemoryStore()
store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy-2.example", Port: 8080, HealthStatus: "healthy", Version: 1}
store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", ImageVersion: "148", Fingerprint: hub.Fingerprint{Seed: 1}}
store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"]}
_ = store.CreateImage(nil, hub.Image{Version: "148", ImageRef: "registry.example/browser:148", Enabled: true})
gateway := &fakeGateway{token: "unit-test-gateway-token", containers: []containerStatus{{ID: "old-container", Alias: "account-a", State: "exited", BindingVersion: 1}}}
app := newTestApp(t, store, gateway)
if response := do(app, http.MethodPost, "/api/browsers/account-a/rebind", `{"network_exit_id":"exit-2"}`); response.Code != http.StatusOK {
t.Fatalf("rebind failed: %d %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodPost, "/api/browsers/account-a/start", ""); response.Code != http.StatusNoContent {
t.Fatalf("start failed: %d %s", response.Code, response.Body.String())
}
requests := gateway.recorded()
var createRequest recordedRequest
for _, request := range requests {
if request.method == http.MethodPost && request.path == "/v1/browsers" {
createRequest = request
}
}
if createRequest.body == nil {
t.Fatalf("stopped container was not recreated: %#v", requests)
}
exit := createRequest.body["network_exit"].(map[string]any)
if exit["host"] != "proxy-2.example" {
t.Fatalf("recreated container did not use rebound exit: %#v", createRequest.body)
}
}
func TestRecycleBrowserKeepsStableEnvironment(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 !kept {
t.Fatal("recycle must preserve the stable environment and Profile anchor")
}
if len(store.actions) != 2 || store.actions[0].Action != "recycle" || store.actions[1].Outcome != "succeeded" {
t.Fatalf("recycle must emit an audit pair: %#v", store.actions)
}
}
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 TestNetworkExitRoutesAreStrictAndSecretFree(t *testing.T) {
store := newMemoryStore()
gateway := &fakeGateway{token: "unit-test-gateway-token"}
app := newTestApp(t, store, gateway)
invalid := do(app, http.MethodPost, "/api/network-exits",
`{"protocol":"socks5","host":"proxy.example","port":1080,"credential_reference":{"id":"credential-a","key":"raw-value"}}`)
if invalid.Code != http.StatusBadRequest || len(store.exits) != 1 {
t.Fatalf("raw credential fields must be rejected before persistence: status=%d exits=%#v", invalid.Code, store.exits)
}
created := do(app, http.MethodPost, "/api/network-exits",
`{"protocol":"socks5","host":"proxy.example","port":1080,"credential_reference":{"id":"credential-a"},"expected_public_ip":"203.0.113.1","expected_region":"test"}`)
if created.Code != http.StatusCreated || strings.Contains(created.Body.String(), "raw-value") {
t.Fatalf("unexpected secret-bearing network exit response: status=%d body=%s", created.Code, created.Body.String())
}
detail := do(app, http.MethodGet, "/api/network-exits/exit-created", "")
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"credential_reference":{"id":"credential-a"`) || strings.Contains(detail.Body.String(), "raw-value") {
t.Fatalf("network exit detail must expose only the credential reference: status=%d body=%s", detail.Code, detail.Body.String())
}
checked := do(app, http.MethodPost, "/api/network-exits/exit-created/check", "")
if checked.Code != http.StatusOK || !strings.Contains(checked.Body.String(), `"health_status":"healthy"`) {
t.Fatalf("network exit check did not record health: status=%d body=%s", checked.Code, checked.Body.String())
}
disabled := do(app, http.MethodPost, "/api/network-exits/exit-created/disable", "")
if disabled.Code != http.StatusOK || !strings.Contains(disabled.Body.String(), `"health_status":"disabled"`) {
t.Fatalf("network exit disable failed: status=%d body=%s", disabled.Code, disabled.Body.String())
}
}
func TestCreateImageReturnsJSONOverHTTP(t *testing.T) {
store := newMemoryStore()
app := fiber.New()
registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil })
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)
}
}