Merge pull request 'fix: 网络出口明文认证存储与创建 Modal 统一关闭' (#45) from fix/network-exit-credentials-modal-close into main
This commit is contained in:
+19
-41
@@ -33,7 +33,7 @@ type hubStore interface {
|
||||
ListEnvs(ctx context.Context) ([]hub.Env, error)
|
||||
GetEnv(ctx context.Context, alias string) (hub.Env, error)
|
||||
UpgradeEnv(ctx context.Context, alias, version string) error
|
||||
CreateNetworkExit(ctx context.Context, exit hub.NetworkExit, credentialReferenceID string) (hub.NetworkExit, error)
|
||||
CreateNetworkExit(ctx context.Context, exit hub.NetworkExit) (hub.NetworkExit, error)
|
||||
ListNetworkExits(ctx context.Context) ([]hub.NetworkExit, error)
|
||||
GetNetworkExit(ctx context.Context, id string) (hub.NetworkExit, error)
|
||||
GetNetworkExitAccess(ctx context.Context, id string) (hub.NetworkExitAccess, error)
|
||||
@@ -451,7 +451,7 @@ func releaseRuntimeWithReconcileAudit(ctx context.Context, store hubStore, envir
|
||||
}
|
||||
|
||||
func registerHub(app *fiber.App, store hubStore) {
|
||||
registerHubWithNetwork(app, store, defaultNetworkExitProbe(), resolveExitCredential)
|
||||
registerHubWithNetwork(app, store, defaultNetworkExitProbe(), nil)
|
||||
}
|
||||
|
||||
func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) {
|
||||
@@ -827,12 +827,11 @@ func getNetworkExit(store hubStore) fiber.Handler {
|
||||
func createNetworkExit(store hubStore) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
var input struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
CredentialReference struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"credential_reference"`
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ExpectedPublicIP string `json:"expected_public_ip"`
|
||||
ExpectedRegion string `json:"expected_region"`
|
||||
}
|
||||
@@ -841,8 +840,9 @@ func createNetworkExit(store hubStore) fiber.Handler {
|
||||
}
|
||||
exit, err := store.CreateNetworkExit(c.Context(), hub.NetworkExit{
|
||||
Protocol: input.Protocol, Host: input.Host, Port: input.Port,
|
||||
Username: input.Username, Password: input.Password,
|
||||
ExpectedPublicIP: input.ExpectedPublicIP, ExpectedRegion: input.ExpectedRegion,
|
||||
}, input.CredentialReference.ID)
|
||||
})
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
@@ -1104,7 +1104,7 @@ func runtimeRecoveryFailure(ctx context.Context, store hubStore, alias string, e
|
||||
}
|
||||
|
||||
func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkExitProbe,
|
||||
resolve func(hub.NetworkExitAccess) (string, error), environment hub.EnvironmentContext, container containerStatus) (bool, error) {
|
||||
_ func(hub.NetworkExitAccess) (string, error), environment hub.EnvironmentContext, container containerStatus) (bool, error) {
|
||||
if environment.RuntimeCleanupPending {
|
||||
target, err := store.GetGateway(ctx, environment.Gateway)
|
||||
if err != nil {
|
||||
@@ -1131,10 +1131,7 @@ func restoreOrRebuildRuntime(ctx context.Context, store hubStore, probe networkE
|
||||
}
|
||||
networkExit := gatewayNetworkExit{}
|
||||
if environment.Exit.ID != "" {
|
||||
networkExit, err = gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
return false, discardRuntime(ctx, store, environment)
|
||||
}
|
||||
networkExit = gatewayNetworkExitFor(access)
|
||||
}
|
||||
if containerMatchesBinding(container, environment) {
|
||||
if environment.Exit.ID == "" {
|
||||
@@ -1270,11 +1267,7 @@ func createBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
|
||||
}
|
||||
networkExit := gatewayNetworkExit{}
|
||||
if input.NetworkExitID != "" {
|
||||
networkExit, err = gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
_ = finish("failed", "credential_unavailable", environment)
|
||||
return hubError(c, hub.ErrConflict)
|
||||
}
|
||||
networkExit = gatewayNetworkExitFor(access)
|
||||
}
|
||||
if !created {
|
||||
container, found, reconcileErr := reconcileGatewayContainer(c.Context(), gateway, env.Alias)
|
||||
@@ -1491,7 +1484,7 @@ func stopEnvironmentRuntime(ctx context.Context, store runtimeStopStore, environ
|
||||
return finish("succeeded", "environment_stopped")
|
||||
}
|
||||
|
||||
func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error), c fiber.Ctx,
|
||||
func startBrowser(store hubStore, probe networkExitProbe, _ func(hub.NetworkExitAccess) (string, error), c fiber.Ctx,
|
||||
environment hub.EnvironmentContext, finish func(string, string, hub.EnvironmentContext) error) error {
|
||||
if !accountRunnable(environment) {
|
||||
return hubError(c, hub.ErrConflict)
|
||||
@@ -1539,15 +1532,7 @@ func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netwo
|
||||
}
|
||||
networkExit := gatewayNetworkExit{}
|
||||
if environment.Exit.ID != "" {
|
||||
networkExit, err = gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil {
|
||||
_ = finish("unknown", "cleanup_result_unknown", environment)
|
||||
return hubError(c, cleanupErr)
|
||||
}
|
||||
_ = finish("failed", "credential_unavailable", environment)
|
||||
return hubError(c, hub.ErrConflict)
|
||||
}
|
||||
networkExit = gatewayNetworkExitFor(access)
|
||||
}
|
||||
container, found, err := reconcileGatewayContainer(c.Context(), gateway, environment.Alias)
|
||||
if err != nil {
|
||||
@@ -1605,7 +1590,7 @@ func startBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netwo
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func upgradeBrowser(store hubStore, probe networkExitProbe, resolve func(hub.NetworkExitAccess) (string, error), c fiber.Ctx) error {
|
||||
func upgradeBrowser(store hubStore, probe networkExitProbe, _ func(hub.NetworkExitAccess) (string, error), c fiber.Ctx) error {
|
||||
var input struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
@@ -1666,11 +1651,7 @@ func upgradeBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Net
|
||||
}
|
||||
}
|
||||
running := accountRunnable(environment)
|
||||
networkExit, err := gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
_ = finish("failed", "credential_unavailable", environment)
|
||||
return hubError(c, hub.ErrConflict)
|
||||
}
|
||||
networkExit := gatewayNetworkExitFor(access)
|
||||
// 先删容器(保留卷);404 视为已删除,保证升级可重试。
|
||||
if _, removeErr := removeGatewayRuntime(c.Context(), store, gateway, environment); removeErr != nil {
|
||||
_ = finish("unknown", "cleanup_result_unknown", environment)
|
||||
@@ -1728,7 +1709,7 @@ type runtimeCreateSpec struct {
|
||||
networkExit gatewayNetworkExit
|
||||
}
|
||||
|
||||
func prepareRuntimeCreate(ctx context.Context, store hubStore, resolve func(hub.NetworkExitAccess) (string, error),
|
||||
func prepareRuntimeCreate(ctx context.Context, store hubStore, _ func(hub.NetworkExitAccess) (string, error),
|
||||
environment hub.EnvironmentContext, access hub.NetworkExitAccess) (runtimeCreateSpec, error) {
|
||||
imageRef, err := store.ImageRef(ctx, environment.ImageVersion)
|
||||
if err != nil {
|
||||
@@ -1736,10 +1717,7 @@ func prepareRuntimeCreate(ctx context.Context, store hubStore, resolve func(hub.
|
||||
}
|
||||
networkExit := gatewayNetworkExit{}
|
||||
if environment.Exit.ID != "" {
|
||||
networkExit, err = gatewayNetworkExitFor(access, resolve)
|
||||
if err != nil {
|
||||
return runtimeCreateSpec{}, err
|
||||
}
|
||||
networkExit = gatewayNetworkExitFor(access)
|
||||
}
|
||||
return runtimeCreateSpec{imageRef: imageRef, networkExit: networkExit}, nil
|
||||
}
|
||||
@@ -2022,7 +2000,7 @@ func rebindBrowser(store hubStore, probe networkExitProbe, resolve func(hub.Netw
|
||||
} else if found {
|
||||
previousAccess, accessErr := store.GetNetworkExitAccess(c.Context(), before.Exit.ID)
|
||||
if accessErr != nil {
|
||||
_ = finish("failed", "credential_unavailable", before)
|
||||
_ = finish("failed", "exit_unavailable", before)
|
||||
return hubError(c, accessErr)
|
||||
}
|
||||
prepared, prepareErr := prepareRuntimeCreate(c.Context(), store, resolve, before, previousAccess)
|
||||
|
||||
@@ -292,13 +292,13 @@ func (s *memoryStore) UpgradeEnv(_ context.Context, alias, version string) error
|
||||
s.bindings[alias] = bound
|
||||
return nil
|
||||
}
|
||||
func (s *memoryStore) CreateNetworkExit(_ context.Context, exit hub.NetworkExit, credentialID string) (hub.NetworkExit, error) {
|
||||
func (s *memoryStore) CreateNetworkExit(_ context.Context, exit hub.NetworkExit) (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"}
|
||||
if len(exit.Username) > 255 || len(exit.Password) > 255 || (exit.Username == "" && exit.Password != "") {
|
||||
return hub.NetworkExit{}, hub.ErrInvalid
|
||||
}
|
||||
exit.ID, exit.HealthStatus, exit.Version = "exit-created", "unchecked", 1
|
||||
s.exits[exit.ID] = exit
|
||||
return exit, nil
|
||||
}
|
||||
@@ -1690,7 +1690,7 @@ func TestUpgradeBrowserUsesCommittedPostgresBinding(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
exit, err := store.CreateNetworkExit(ctx, hub.NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080}, "")
|
||||
exit, err := store.CreateNetworkExit(ctx, hub.NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1872,7 +1872,7 @@ func newPostgresRebindFixture(t *testing.T, databaseURL string) postgresRebindFi
|
||||
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}, "")
|
||||
exit, err := store.CreateNetworkExit(ctx, hub.NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -4596,42 +4596,22 @@ func TestCleanupPendingBlocksEveryLifecyclePath(t *testing.T) {
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
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"}
|
||||
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{}, nil)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4669,23 +4649,13 @@ func TestExistingEnvironmentCleanupNeverReturnsReusedSuccess(t *testing.T) {
|
||||
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
|
||||
}
|
||||
}()},
|
||||
resolve: func(hub.NetworkExitAccess) (string, error) { return "", 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"},
|
||||
Username: "username", Password: "password",
|
||||
}
|
||||
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{
|
||||
@@ -4782,24 +4752,26 @@ func TestGatewayAndImageCRUDRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkExitRoutesAreStrictAndSecretFree(t *testing.T) {
|
||||
func TestNetworkExitRoutesStoreAndExposePlainCredentials(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"}}`)
|
||||
`{"protocol":"socks5","host":"proxy.example","port":1080,"password":"password-only"}`)
|
||||
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)
|
||||
t.Fatalf("password without username must be rejected: 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())
|
||||
`{"protocol":"socks5","host":"proxy.example","port":1080,"username":"proxy-user","password":"plain-password","expected_public_ip":"203.0.113.1","expected_region":"test"}`)
|
||||
if created.Code != http.StatusCreated || !strings.Contains(created.Body.String(), `"username":"proxy-user"`) ||
|
||||
!strings.Contains(created.Body.String(), `"password":"plain-password"`) {
|
||||
t.Fatalf("network exit response must expose stored credentials: 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())
|
||||
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"username":"proxy-user"`) ||
|
||||
!strings.Contains(detail.Body.String(), `"password":"plain-password"`) {
|
||||
t.Fatalf("network exit detail must expose stored credentials: 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"`) {
|
||||
|
||||
@@ -119,7 +119,7 @@ func runtimeLeaseHeartbeat(ctx context.Context, store hubStore) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := reconcileRuntimeLeases(ctx, store, defaultNetworkExitProbe(), resolveExitCredential); err != nil && ctx.Err() == nil {
|
||||
if err := reconcileRuntimeLeases(ctx, store, defaultNetworkExitProbe(), nil); err != nil && ctx.Err() == nil {
|
||||
logrus.WithField("service", "control-plane").WithError(err).Warn("runtime lease reconciliation failed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -12,7 +10,6 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,29 +25,23 @@ type networkExitProbe interface {
|
||||
type httpNetworkExitProbe struct {
|
||||
endpoint string
|
||||
client *http.Client
|
||||
resolve func(hub.NetworkExitAccess) (string, error)
|
||||
}
|
||||
|
||||
func defaultNetworkExitProbe() networkExitProbe {
|
||||
return httpNetworkExitProbe{endpoint: networkExitObservationURL, client: &http.Client{Timeout: 20 * time.Second}, resolve: resolveExitCredential}
|
||||
return httpNetworkExitProbe{endpoint: networkExitObservationURL, client: &http.Client{Timeout: 20 * time.Second}}
|
||||
}
|
||||
|
||||
func (probe httpNetworkExitProbe) Check(ctx context.Context, exit hub.NetworkExitAccess) (hub.ExitObservation, string) {
|
||||
proxyURL := &url.URL{Scheme: exit.Protocol, Host: net.JoinHostPort(exit.Host, fmt.Sprint(exit.Port))}
|
||||
proxyUsername := ""
|
||||
if exit.CredentialReference != nil {
|
||||
secret, err := probe.resolve(exit)
|
||||
if err != nil {
|
||||
return hub.ExitObservation{}, "credential_unavailable"
|
||||
}
|
||||
username, password, found := strings.Cut(secret, ":")
|
||||
if !found || username == "" {
|
||||
return hub.ExitObservation{}, "credential_invalid"
|
||||
}
|
||||
proxyUsername = username
|
||||
proxyURL.User = url.UserPassword(username, password)
|
||||
proxyUsername := exit.Username
|
||||
if exit.Username != "" {
|
||||
proxyURL.User = url.UserPassword(exit.Username, exit.Password)
|
||||
}
|
||||
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if configured, ok := probe.client.Transport.(*http.Transport); ok {
|
||||
transport = configured.Clone()
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
if exit.Protocol == "socks4" {
|
||||
transport.Proxy = nil
|
||||
transport.DialContext = socks4DialContext(proxyURL.Host, proxyUsername)
|
||||
@@ -134,24 +125,6 @@ func socks4DialContext(proxyAddress, userID string) func(context.Context, string
|
||||
}
|
||||
}
|
||||
|
||||
// Secret managers and keyring bridges inject the referenced value at process start.
|
||||
// Only the resolved username:password value is kept in the request-local call stack.
|
||||
func resolveExitCredential(exit hub.NetworkExitAccess) (string, error) {
|
||||
if exit.CredentialReference == nil || exit.CredentialKey == "" {
|
||||
return "", errors.New("credential reference unavailable")
|
||||
}
|
||||
value, ok := os.LookupEnv(credentialEnvironmentName(exit.CredentialKey))
|
||||
if !ok || value == "" {
|
||||
return "", errors.New("credential value unavailable")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func credentialEnvironmentName(key string) string {
|
||||
digest := sha256.Sum256([]byte(key))
|
||||
return "CREATORHUB_CREDENTIAL_" + strings.ToUpper(hex.EncodeToString(digest[:]))
|
||||
}
|
||||
|
||||
type gatewayNetworkExit struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
@@ -160,19 +133,12 @@ type gatewayNetworkExit struct {
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
func gatewayNetworkExitFor(exit hub.NetworkExitAccess, resolve func(hub.NetworkExitAccess) (string, error)) (gatewayNetworkExit, error) {
|
||||
result := gatewayNetworkExit{Protocol: exit.Protocol, Host: exit.Host, Port: exit.Port}
|
||||
if exit.CredentialReference == nil {
|
||||
return result, nil
|
||||
func gatewayNetworkExitFor(exit hub.NetworkExitAccess) gatewayNetworkExit {
|
||||
return gatewayNetworkExit{
|
||||
Protocol: exit.Protocol,
|
||||
Host: exit.Host,
|
||||
Port: exit.Port,
|
||||
Username: exit.Username,
|
||||
Password: exit.Password,
|
||||
}
|
||||
secret, err := resolve(exit)
|
||||
if err != nil {
|
||||
return gatewayNetworkExit{}, errors.New("credential unavailable")
|
||||
}
|
||||
username, password, found := strings.Cut(secret, ":")
|
||||
if !found || username == "" {
|
||||
return gatewayNetworkExit{}, errors.New("credential invalid")
|
||||
}
|
||||
result.Username, result.Password = username, password
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,17 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
||||
)
|
||||
|
||||
func TestHTTPNetworkExitProbeUsesStoredBasicAuth(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
want := "Basic " + base64.StdEncoding.EncodeToString([]byte("operator:plain-password"))
|
||||
if request.Header.Get("Proxy-Authorization") != want {
|
||||
response.WriteHeader(http.StatusProxyAuthRequired)
|
||||
return
|
||||
}
|
||||
_, _ = response.Write([]byte(`{"ip":"203.0.113.10","region":"Shanghai"}`))
|
||||
}))
|
||||
defer proxy.Close()
|
||||
|
||||
exit := networkExitForURL(t, "http", proxy.URL)
|
||||
exit.Username, exit.Password = "operator", "plain-password"
|
||||
observation, reason := (httpNetworkExitProbe{endpoint: "http://observation.test/json", client: &http.Client{Timeout: time.Second}}).Check(context.Background(), exit)
|
||||
if reason != "" || observation.PublicIP != "203.0.113.10" || observation.Region != "Shanghai" {
|
||||
t.Fatalf("authenticated HTTP probe failed: observation=%#v reason=%q", observation, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSNetworkExitProbeUsesStoredBasicAuth(t *testing.T) {
|
||||
proxy := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
want := "Basic " + base64.StdEncoding.EncodeToString([]byte("operator:plain-password"))
|
||||
if request.Header.Get("Proxy-Authorization") != want {
|
||||
response.WriteHeader(http.StatusProxyAuthRequired)
|
||||
return
|
||||
}
|
||||
_, _ = response.Write([]byte(`{"ip":"203.0.113.12","region":"Shenzhen"}`))
|
||||
}))
|
||||
defer proxy.Close()
|
||||
|
||||
exit := networkExitForURL(t, "https", proxy.URL)
|
||||
exit.Username, exit.Password = "operator", "plain-password"
|
||||
client := proxy.Client()
|
||||
client.Timeout = time.Second
|
||||
observation, reason := (httpNetworkExitProbe{endpoint: "http://observation.test/json", client: client}).Check(context.Background(), exit)
|
||||
if reason != "" || observation.PublicIP != "203.0.113.12" || observation.Region != "Shenzhen" {
|
||||
t.Fatalf("authenticated HTTPS probe failed: observation=%#v reason=%q", observation, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCKS5NetworkExitProbeUsesStoredCredentials(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- serveAuthenticatedSOCKS5(listener, "operator", "plain-password") }()
|
||||
|
||||
host, portText, _ := net.SplitHostPort(listener.Addr().String())
|
||||
exit := hub.NetworkExitAccess{NetworkExit: hub.NetworkExit{
|
||||
Protocol: "socks5", Host: host, Port: mustPort(t, portText), Username: "operator", Password: "plain-password",
|
||||
}}
|
||||
observation, reason := (httpNetworkExitProbe{endpoint: "http://observation.test/json", client: &http.Client{Timeout: time.Second}}).Check(context.Background(), exit)
|
||||
if reason != "" || observation.PublicIP != "203.0.113.11" || observation.Region != "Beijing" {
|
||||
t.Fatalf("authenticated SOCKS5 probe failed: observation=%#v reason=%q", observation, reason)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func networkExitForURL(t *testing.T, protocol, rawURL string) hub.NetworkExitAccess {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return hub.NetworkExitAccess{NetworkExit: hub.NetworkExit{Protocol: protocol, Host: host, Port: mustPort(t, portText)}}
|
||||
}
|
||||
|
||||
func mustPort(t *testing.T, value string) int {
|
||||
t.Helper()
|
||||
port, err := net.LookupPort("tcp", value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func serveAuthenticatedSOCKS5(listener net.Listener, username, password string) error {
|
||||
connection, err := listener.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer connection.Close()
|
||||
reader := bufio.NewReader(connection)
|
||||
greeting := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, greeting); err != nil {
|
||||
return err
|
||||
}
|
||||
methods := make([]byte, int(greeting[1]))
|
||||
if _, err := io.ReadFull(reader, methods); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := connection.Write([]byte{5, 2}); err != nil {
|
||||
return err
|
||||
}
|
||||
authHeader := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, authHeader); err != nil {
|
||||
return err
|
||||
}
|
||||
user := make([]byte, int(authHeader[1]))
|
||||
if _, err := io.ReadFull(reader, user); err != nil {
|
||||
return err
|
||||
}
|
||||
passwordLength, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secret := make([]byte, int(passwordLength))
|
||||
if _, err := io.ReadFull(reader, secret); err != nil {
|
||||
return err
|
||||
}
|
||||
if string(user) != username || string(secret) != password {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if _, err := connection.Write([]byte{1, 0}); err != nil {
|
||||
return err
|
||||
}
|
||||
requestHeader := make([]byte, 4)
|
||||
if _, err := io.ReadFull(reader, requestHeader); err != nil {
|
||||
return err
|
||||
}
|
||||
addressLength := 4
|
||||
switch requestHeader[3] {
|
||||
case 3:
|
||||
length, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addressLength = int(length)
|
||||
case 4:
|
||||
addressLength = 16
|
||||
}
|
||||
if _, err := io.CopyN(io.Discard, reader, int64(addressLength+2)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := connection.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := http.ReadRequest(reader); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.WriteString(connection, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 40\r\nConnection: close\r\n\r\n{\"ip\":\"203.0.113.11\",\"region\":\"Beijing\"}")
|
||||
return err
|
||||
}
|
||||
|
||||
func TestSOCKS4DialerUsesBoundProxy(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
@@ -62,20 +220,18 @@ func TestSOCKS4DialerUsesBoundProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayNetworkExitResolvesCredentialWithoutPersistingIt(t *testing.T) {
|
||||
func TestGatewayNetworkExitUsesStoredPlainCredentials(t *testing.T) {
|
||||
exit := hub.NetworkExitAccess{NetworkExit: hub.NetworkExit{
|
||||
Protocol: "socks5", Host: "proxy.example", Port: 1080,
|
||||
CredentialReference: &hub.CredentialReference{ID: "credential-a", Provider: "os_keyring"},
|
||||
Username: "operator", Password: "plain-password",
|
||||
}}
|
||||
gatewayExit, err := gatewayNetworkExitFor(exit, func(hub.NetworkExitAccess) (string, error) {
|
||||
return "operator:ephemeral-value", nil
|
||||
})
|
||||
if err != nil || gatewayExit.Username != "operator" || gatewayExit.Password != "ephemeral-value" || gatewayExit.Host != "proxy.example" {
|
||||
t.Fatalf("credential was not resolved into the request-local gateway payload: %#v err=%v", gatewayExit, err)
|
||||
gatewayExit := gatewayNetworkExitFor(exit)
|
||||
if gatewayExit.Username != "operator" || gatewayExit.Password != "plain-password" || gatewayExit.Host != "proxy.example" {
|
||||
t.Fatalf("stored credential was not copied into the gateway payload: %#v", gatewayExit)
|
||||
}
|
||||
encoded := string(mustJSON(t, exit.NetworkExit))
|
||||
if strings.Contains(encoded, "ephemeral-value") {
|
||||
t.Fatalf("network exit persistence model contains resolved credential: %s", encoded)
|
||||
if !strings.Contains(encoded, `"username":"operator"`) || !strings.Contains(encoded, `"password":"plain-password"`) {
|
||||
t.Fatalf("network exit API model must expose stored credentials: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-49
@@ -17,33 +17,28 @@ import (
|
||||
|
||||
var exitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
|
||||
|
||||
type CredentialReference struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
type NetworkExit struct {
|
||||
ID string `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
CredentialReference *CredentialReference `json:"credential_reference,omitempty"`
|
||||
ExpectedPublicIP string `json:"expected_public_ip,omitempty"`
|
||||
ExpectedRegion string `json:"expected_region,omitempty"`
|
||||
ObservedPublicIP string `json:"observed_public_ip,omitempty"`
|
||||
ObservedRegion string `json:"observed_region,omitempty"`
|
||||
HealthStatus string `json:"health_status"`
|
||||
LastCheckReason string `json:"last_check_reason,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ExpectedPublicIP string `json:"expected_public_ip,omitempty"`
|
||||
ExpectedRegion string `json:"expected_region,omitempty"`
|
||||
ObservedPublicIP string `json:"observed_public_ip,omitempty"`
|
||||
ObservedRegion string `json:"observed_region,omitempty"`
|
||||
HealthStatus string `json:"health_status"`
|
||||
LastCheckReason string `json:"last_check_reason,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NetworkExitAccess is internal-only: reference keys are never serialized or audited.
|
||||
// NetworkExitAccess is the internal runtime view of a persisted network exit.
|
||||
type NetworkExitAccess struct {
|
||||
NetworkExit
|
||||
CredentialKey string `json:"-"`
|
||||
}
|
||||
|
||||
type ExitObservation struct {
|
||||
@@ -83,18 +78,17 @@ type EnvironmentAction struct {
|
||||
ReasonCode string
|
||||
}
|
||||
|
||||
func (s *Store) CreateNetworkExit(ctx context.Context, exit NetworkExit, credentialReferenceID string) (NetworkExit, error) {
|
||||
func (s *Store) CreateNetworkExit(ctx context.Context, exit NetworkExit) (NetworkExit, error) {
|
||||
exit.ID = "exit-" + newHubID()
|
||||
exit.Protocol, exit.Host = strings.ToLower(strings.TrimSpace(exit.Protocol)), strings.TrimSpace(exit.Host)
|
||||
exit.ExpectedPublicIP, exit.ExpectedRegion = strings.TrimSpace(exit.ExpectedPublicIP), strings.TrimSpace(exit.ExpectedRegion)
|
||||
credentialReferenceID = strings.TrimSpace(credentialReferenceID)
|
||||
if !validNetworkExit(exit) || (credentialReferenceID != "" && !exitIDPattern.MatchString(credentialReferenceID)) {
|
||||
if !validNetworkExit(exit) {
|
||||
return NetworkExit{}, ErrInvalid
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO network_exit (id, protocol, host, port, credential_reference_id, expected_public_ip, expected_region)
|
||||
VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, '')::inet, $7)
|
||||
RETURNING id`, exit.ID, exit.Protocol, exit.Host, exit.Port, credentialReferenceID, exit.ExpectedPublicIP, exit.ExpectedRegion)
|
||||
INSERT INTO network_exit (id, protocol, host, port, username, password, expected_public_ip, expected_region)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NULLIF($7, '')::inet, $8)
|
||||
RETURNING id`, exit.ID, exit.Protocol, exit.Host, exit.Port, exit.Username, exit.Password, exit.ExpectedPublicIP, exit.ExpectedRegion)
|
||||
if err := row.Scan(&exit.ID); err != nil {
|
||||
return NetworkExit{}, publicDatabaseError(err)
|
||||
}
|
||||
@@ -105,7 +99,7 @@ func validNetworkExit(exit NetworkExit) bool {
|
||||
if exit.Protocol != "http" && exit.Protocol != "https" && exit.Protocol != "socks4" && exit.Protocol != "socks5" {
|
||||
return false
|
||||
}
|
||||
if !validExitHost(exit.Host) || exit.Port < 1 || exit.Port > 65535 {
|
||||
if !validExitHost(exit.Host) || exit.Port < 1 || exit.Port > 65535 || !validExitCredentials(exit.Username, exit.Password) {
|
||||
return false
|
||||
}
|
||||
if exit.ExpectedPublicIP != "" && net.ParseIP(exit.ExpectedPublicIP) == nil {
|
||||
@@ -114,6 +108,18 @@ func validNetworkExit(exit NetworkExit) bool {
|
||||
return validOptionalRegion(exit.ExpectedRegion)
|
||||
}
|
||||
|
||||
func validExitCredentials(username, password string) bool {
|
||||
if len(username) > 255 || len(password) > 255 || (username == "" && password != "") {
|
||||
return false
|
||||
}
|
||||
for _, value := range username + password {
|
||||
if value < 0x20 || value == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validExitHost(host string) bool {
|
||||
if host == "" || len(host) > 253 || strings.ContainsAny(host, "@/[]?# \t\r\n") {
|
||||
return false
|
||||
@@ -176,43 +182,27 @@ func (s *Store) GetNetworkExit(ctx context.Context, id string) (NetworkExit, err
|
||||
|
||||
func (s *Store) GetNetworkExitAccess(ctx context.Context, id string) (NetworkExitAccess, error) {
|
||||
exit, err := s.GetNetworkExit(ctx, id)
|
||||
if err != nil {
|
||||
return NetworkExitAccess{}, err
|
||||
}
|
||||
access := NetworkExitAccess{NetworkExit: exit}
|
||||
if exit.CredentialReference != nil {
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT reference_key FROM credential_reference WHERE id = $1`, exit.CredentialReference.ID).
|
||||
Scan(&access.CredentialKey); err != nil {
|
||||
return NetworkExitAccess{}, rowError(err)
|
||||
}
|
||||
}
|
||||
return access, nil
|
||||
return NetworkExitAccess{NetworkExit: exit}, err
|
||||
}
|
||||
|
||||
const networkExitSelect = `
|
||||
SELECT network.id, network.protocol, network.host, network.port,
|
||||
reference.id, reference.provider,
|
||||
SELECT network.id, network.protocol, network.host, network.port, network.username, network.password,
|
||||
COALESCE(host(network.expected_public_ip), ''), network.expected_region,
|
||||
COALESCE(host(network.observed_public_ip), ''), network.observed_region,
|
||||
network.health_status, COALESCE(network.last_check_reason, ''), network.version, network.last_checked_at,
|
||||
network.created_at, network.updated_at
|
||||
FROM network_exit network
|
||||
LEFT JOIN credential_reference reference ON reference.id = network.credential_reference_id`
|
||||
FROM network_exit network`
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func scanNetworkExit(row rowScanner) (NetworkExit, error) {
|
||||
var exit NetworkExit
|
||||
var referenceID, provider sql.NullString
|
||||
var checked sql.NullTime
|
||||
if err := row.Scan(&exit.ID, &exit.Protocol, &exit.Host, &exit.Port, &referenceID, &provider,
|
||||
if err := row.Scan(&exit.ID, &exit.Protocol, &exit.Host, &exit.Port, &exit.Username, &exit.Password,
|
||||
&exit.ExpectedPublicIP, &exit.ExpectedRegion, &exit.ObservedPublicIP, &exit.ObservedRegion,
|
||||
&exit.HealthStatus, &exit.LastCheckReason, &exit.Version, &checked, &exit.CreatedAt, &exit.UpdatedAt); err != nil {
|
||||
return NetworkExit{}, rowError(err)
|
||||
}
|
||||
if referenceID.Valid {
|
||||
exit.CredentialReference = &CredentialReference{ID: referenceID.String, Provider: provider.String}
|
||||
}
|
||||
if checked.Valid {
|
||||
exit.LastCheckedAt = &checked.Time
|
||||
}
|
||||
|
||||
@@ -33,16 +33,17 @@ func TestUnifiedAccountMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 15`, 15)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 16`, 16)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('social_account', 'browser_env', 'network_exit', 'environment_binding')`, 4)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name IN ('name', 'tags')`, 2)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name = 'cookies'`, 0)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'environment_binding' AND column_name = 'runtime_cleanup_pending'`, 1)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'environment_binding' AND column_name LIKE 'runtime_cleanup_%'`, 5)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'network_exit' AND column_name IN ('username', 'password')`, 2)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'network_exit' AND column_name = 'credential_reference_id'`, 0)
|
||||
|
||||
store = openFullyMigratedHub(t, ctx, testURL)
|
||||
store.Close()
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 15`, 15)
|
||||
assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 16`, 16)
|
||||
})
|
||||
|
||||
t.Run("legacy migration 013 without account secrets is repaired forward", func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 内部系统直接保存并使用网络出口认证信息;移除外部凭据引用链路。
|
||||
ALTER TABLE network_exit
|
||||
ADD COLUMN username text NOT NULL DEFAULT '' CHECK (length(username) <= 255),
|
||||
ADD COLUMN password text NOT NULL DEFAULT '' CHECK (length(password) <= 255),
|
||||
DROP COLUMN credential_reference_id;
|
||||
|
||||
ALTER TABLE network_exit
|
||||
ADD CONSTRAINT network_exit_credentials_pair_check
|
||||
CHECK (password = '' OR username <> '');
|
||||
@@ -63,6 +63,9 @@ var migration014 string
|
||||
//go:embed migrations/015_gateway_rename_cascade.sql
|
||||
var migration015 string
|
||||
|
||||
//go:embed migrations/016_network_exit_plain_credentials.sql
|
||||
var migration016 string
|
||||
|
||||
var (
|
||||
ErrConflict = errors.New("resource conflicts with existing state")
|
||||
ErrInvalid = errors.New("invalid hub input")
|
||||
@@ -219,7 +222,7 @@ func (s *Store) migrate(ctx context.Context) error {
|
||||
for _, migration := range []struct {
|
||||
version int
|
||||
sql string
|
||||
}{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}} {
|
||||
}{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}, {16, migration016}} {
|
||||
var applied bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil {
|
||||
return errors.New("read hub schema migration state")
|
||||
|
||||
+32
-10
@@ -16,6 +16,28 @@ import (
|
||||
"git.ipao.vip/rogee/creator-hub/internal/taskstate"
|
||||
)
|
||||
|
||||
func TestNetworkExitCredentialValidation(t *testing.T) {
|
||||
valid := NetworkExit{Protocol: "socks5", Host: "proxy.example", Port: 1080, Username: "operator", Password: "plain-password"}
|
||||
if !validNetworkExit(valid) {
|
||||
t.Fatal("valid stored credentials were rejected")
|
||||
}
|
||||
for name, mutate := range map[string]func(*NetworkExit){
|
||||
"password without username": func(exit *NetworkExit) { exit.Username = "" },
|
||||
"username too long": func(exit *NetworkExit) { exit.Username = strings.Repeat("u", 256) },
|
||||
"password too long": func(exit *NetworkExit) { exit.Password = strings.Repeat("p", 256) },
|
||||
"username control character": func(exit *NetworkExit) { exit.Username = "operator\n" },
|
||||
"password control character": func(exit *NetworkExit) { exit.Password = "plain\x7fpassword" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
exit := valid
|
||||
mutate(&exit)
|
||||
if validNetworkExit(exit) {
|
||||
t.Fatalf("invalid credentials were accepted: %#v", exit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentLocksCoordinateAcrossStoreInstances(t *testing.T) {
|
||||
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
|
||||
if databaseURL == "" {
|
||||
@@ -262,7 +284,7 @@ func TestStoreValidationRejectsInvalidInputsBeforePersistence(t *testing.T) {
|
||||
"ip": {Protocol: "socks5", Host: "proxy.example", Port: 1080, ExpectedPublicIP: "not-an-ip"},
|
||||
} {
|
||||
t.Run("network exit "+name, func(t *testing.T) {
|
||||
if _, err := store.CreateNetworkExit(ctx, exit, ""); !errors.Is(err, ErrInvalid) {
|
||||
if _, err := store.CreateNetworkExit(ctx, exit); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected invalid network exit, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -415,8 +437,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO credential_reference (id, provider, reference_key)
|
||||
VALUES ('credential-exit', 'os_keyring', 'creatorhub/proxy-main'),
|
||||
('credential-account', 'os_keyring', 'creatorhub/account-a');
|
||||
VALUES ('credential-account', 'os_keyring', 'creatorhub/account-a');
|
||||
INSERT INTO social_account
|
||||
(id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status)
|
||||
VALUES ('account-a', 'credential-account', 'mock', 'account-a', 'owned', 'authorized')`); err != nil {
|
||||
@@ -431,18 +452,19 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
|
||||
exit, err := store.CreateNetworkExit(ctx, NetworkExit{
|
||||
Protocol: "socks5", Host: "proxy.example", Port: 1080,
|
||||
Username: "proxy-user", Password: "plain-password",
|
||||
ExpectedPublicIP: "203.0.113.10", ExpectedRegion: "test-region",
|
||||
}, "credential-exit")
|
||||
if err != nil || exit.HealthStatus != "unchecked" || exit.CredentialReference == nil || exit.CredentialReference.ID != "credential-exit" {
|
||||
})
|
||||
if err != nil || exit.HealthStatus != "unchecked" || exit.Username != "proxy-user" || exit.Password != "plain-password" {
|
||||
t.Fatalf("unexpected network exit: %#v err=%v", exit, err)
|
||||
}
|
||||
exported, _ := json.Marshal(exit)
|
||||
if strings.Contains(string(exported), "creatorhub/proxy-main") {
|
||||
t.Fatalf("network exit response leaked a credential reference key: %s", exported)
|
||||
if !strings.Contains(string(exported), `"username":"proxy-user"`) || !strings.Contains(string(exported), `"password":"plain-password"`) {
|
||||
t.Fatalf("network exit response must include stored credentials: %s", exported)
|
||||
}
|
||||
access, err := store.GetNetworkExitAccess(ctx, exit.ID)
|
||||
if err != nil || access.CredentialKey != "creatorhub/proxy-main" {
|
||||
t.Fatalf("runtime-only credential resolution data unavailable: %#v err=%v", access, err)
|
||||
if err != nil || access.Username != "proxy-user" || access.Password != "plain-password" {
|
||||
t.Fatalf("runtime network exit credentials unavailable: %#v err=%v", access, err)
|
||||
}
|
||||
|
||||
exit, reason, err := store.RecordNetworkExitCheck(ctx, exit.ID, ExitObservation{PublicIP: "203.0.113.11", Region: "test-region"}, "")
|
||||
@@ -475,7 +497,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) {
|
||||
AND network_exit_id = $1 AND runtime_instance_id = $2 AND binding_version = $3 AND details = '{}'::jsonb`,
|
||||
1, active.Exit.ID, active.RuntimeInstanceID, active.BindingVersion)
|
||||
|
||||
second, err := store.CreateNetworkExit(ctx, NetworkExit{Protocol: "http", Host: "proxy-2.example", Port: 8080}, "")
|
||||
second, err := store.CreateNetworkExit(ctx, NetworkExit{Protocol: "http", Host: "proxy-2.example", Port: 8080})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -133,7 +133,10 @@ function AccountCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
// cookies 非必填:留空代表创建后走扫码登录,凭据由后续同步链路补齐
|
||||
if (form.cookies.trim()) data.cookies = form.cookies.trim();
|
||||
const created = await onSubmit(data);
|
||||
if (created) setForm(createInitial);
|
||||
if (created) {
|
||||
setForm(createInitial);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -290,7 +293,6 @@ export function AccountList() {
|
||||
variant: "success",
|
||||
text: "账号已创建;绑定健康出口和运行环境后方可恢复。",
|
||||
});
|
||||
setCreateOpen(false);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setCreateError(reason);
|
||||
|
||||
@@ -21,7 +21,10 @@ function ImageCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
event.preventDefault()
|
||||
if (!valid) return
|
||||
const created = await onSubmit({ version: form.version, image_ref: form.image_ref, note: form.note, enabled: true })
|
||||
if (created) setForm({ version: '', image_ref: '', note: '' })
|
||||
if (created) {
|
||||
setForm({ version: '', image_ref: '', note: '' })
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -44,6 +44,22 @@ describe('BrowserImageList', () => {
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '添加版本' }))
|
||||
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith({ resource: 'browser-images', variables: { version: '150.0.0.1', image_ref: 'reg/img:150', note: '', enabled: true } }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
})
|
||||
|
||||
it('keeps the create modal open after a failure', async () => {
|
||||
const dataProvider = provider({ create: vi.fn().mockRejectedValue(new Error('镜像创建失败')) })
|
||||
renderImages(dataProvider)
|
||||
|
||||
await screen.findAllByText('reg/img:148')
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加版本' }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '版本' }), { target: { value: '150.0.0.1' } })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '镜像引用' }), { target: { value: 'reg/img:150' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '添加版本' }))
|
||||
|
||||
expect((await within(dialog).findByRole('alert')).textContent).toContain('镜像创建失败')
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('toggles enabled through update', async () => {
|
||||
|
||||
@@ -105,6 +105,7 @@ function GatewayFormModal({ open, onClose, onSubmit, busy, error, initial }) {
|
||||
token,
|
||||
})
|
||||
) {
|
||||
setForm({ name: "", endpoint: "", token: "" });
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,36 @@ describe("GatewayList", () => {
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"generated-token-abcdef",
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "注册网关" }));
|
||||
const reopened = screen.getByRole("dialog");
|
||||
expect(within(reopened).getByRole("textbox", { name: "名称" }).value).toBe("");
|
||||
expect(within(reopened).getByRole("textbox", { name: "Endpoint" }).value).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the registration modal open after a failure", async () => {
|
||||
const dataProvider = provider({
|
||||
create: vi.fn().mockRejectedValue(new Error("网关名称已存在")),
|
||||
});
|
||||
renderGateways(dataProvider);
|
||||
await screen.findAllByText("gw-1");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "注册网关" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "名称" }), {
|
||||
target: { value: "gw-2" },
|
||||
});
|
||||
fireEvent.change(
|
||||
within(dialog).getByRole("textbox", { name: "Endpoint" }),
|
||||
{ target: { value: "http://gw2:8081" } },
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "注册网关" }));
|
||||
|
||||
expect((await within(dialog).findByRole("alert")).textContent).toContain(
|
||||
"网关名称已存在",
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects an invalid endpoint before submit", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router'
|
||||
import { Link, useParams } from 'react-router'
|
||||
import { useDataProvider, useList, useOne } from '@refinedev/core'
|
||||
import {
|
||||
Alert, Button, Card, CardContent, ConfirmDialog, DetailList, Field, Input, Modal, PageHeader,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from './lib/ui.jsx'
|
||||
import { useTitle } from './lib/hooks.js'
|
||||
|
||||
const createInitial = { protocol: 'socks5', host: '', port: '', credential_reference_id: '', expected_public_ip: '', expected_region: '' }
|
||||
const createInitial = { protocol: 'socks5', host: '', port: '', username: '', password: '', expected_public_ip: '', expected_region: '' }
|
||||
const healthText = { unchecked: '未检测', healthy: '健康', unhealthy: '不健康', disabled: '已停用' }
|
||||
const protocolOptions = ['http', 'https', 'socks4', 'socks5'].map(value => ({ value, label: value }))
|
||||
|
||||
@@ -31,17 +31,21 @@ function ExitCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
const [form, setForm] = useState(createInitial)
|
||||
const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
|
||||
const port = Number(form.port)
|
||||
const valid = form.host.trim() && Number.isInteger(port) && port > 0 && port <= 65535
|
||||
const credentialsValid = !form.password || !!form.username
|
||||
const valid = form.host.trim() && Number.isInteger(port) && port > 0 && port <= 65535 && credentialsValid
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
if (!valid) return
|
||||
const created = await onSubmit({
|
||||
protocol: form.protocol, host: form.host.trim(), port,
|
||||
credential_reference: { id: form.credential_reference_id.trim() },
|
||||
username: form.username, password: form.password,
|
||||
expected_public_ip: form.expected_public_ip.trim(), expected_region: form.expected_region.trim(),
|
||||
})
|
||||
if (created) setForm(createInitial)
|
||||
if (created) {
|
||||
setForm(createInitial)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -51,7 +55,7 @@ function ExitCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
<Button variant="primary" type="submit" form="exit-create-form" busy={busy} busyText="创建中…" disabled={!valid}>创建网络出口</Button>
|
||||
</>}>
|
||||
<form id="exit-create-form" onSubmit={submit} noValidate>
|
||||
{error ? <Alert variant="destructive" className="mb-4">{conflictMessage(error, '出口或认证引用与现有资源冲突;表单内容已保留。')}</Alert> : null}
|
||||
{error ? <Alert variant="destructive" className="mb-4">{conflictMessage(error, '出口地址或认证信息与现有资源冲突;表单内容已保留。')}</Alert> : null}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field id="exit-protocol" label="协议" required helper="代理协议">
|
||||
<Select id="exit-protocol" value={form.protocol} onChange={event => update('protocol', event.target.value)} options={protocolOptions} />
|
||||
@@ -62,8 +66,11 @@ function ExitCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||||
<Field id="exit-port" label="端口" required helper="1..65535">
|
||||
<Input id="exit-port" type="number" min={1} max={65535} required value={form.port} onChange={event => update('port', event.target.value)} />
|
||||
</Field>
|
||||
<Field id="exit-credential" label="认证(可选)" helper="只填已保存的凭据引用 ID,不填认证秘密">
|
||||
<Input id="exit-credential" maxLength={128} value={form.credential_reference_id} onChange={event => update('credential_reference_id', event.target.value)} />
|
||||
<Field id="exit-username" label="用户名(可选)" helper="直接保存到系统并用于代理认证">
|
||||
<Input id="exit-username" maxLength={255} value={form.username} onChange={event => update('username', event.target.value)} />
|
||||
</Field>
|
||||
<Field id="exit-password" label="密码(可选)" error={!credentialsValid ? '填写密码时必须同时填写用户名' : undefined} helper="明文保存并直接用于代理认证">
|
||||
<Input id="exit-password" maxLength={255} value={form.password} onChange={event => update('password', event.target.value)} invalid={!credentialsValid} />
|
||||
</Field>
|
||||
<Field id="exit-ip" label="出口IP(可选)" helper="健康检测时比对的预期公网 IP">
|
||||
<Input id="exit-ip" value={form.expected_public_ip} onChange={event => update('expected_public_ip', event.target.value)} />
|
||||
@@ -84,7 +91,7 @@ function ExitCard({ exit, boundAccounts, bindingsError, busy, onAction }) {
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link to={`/network-exits/${encodeURIComponent(exit.id)}`} className="anywhere font-semibold text-ink hover:text-primary">{exit.protocol}://{exit.host}:{exit.port}</Link>
|
||||
<p className="anywhere text-xs text-muted">{exit.id} · 认证 {exit.credential_reference?.id || '无'}</p>
|
||||
<p className="anywhere text-xs text-muted">{exit.id} · 用户名 {exit.username || '无'} · 密码 {exit.password || '无'}</p>
|
||||
</div>
|
||||
<ExitHealthPill exit={exit} />
|
||||
</div>
|
||||
@@ -204,7 +211,8 @@ export function NetworkExitDetail() {
|
||||
<DetailList rows={[
|
||||
['ID', <span className="anywhere">{exit.id}</span>],
|
||||
['健康 / 版本', <span className="anywhere">{healthText[exit.health_status] ?? exit.health_status} · {exit.version}</span>],
|
||||
['认证', <span className="anywhere">{exit.credential_reference?.id || '无'}</span>],
|
||||
['用户名', <span className="anywhere">{exit.username || '无'}</span>],
|
||||
['密码', <span className="anywhere">{exit.password || '无'}</span>],
|
||||
['出口IP', <span className="anywhere">{exit.observed_public_ip || '尚无观测'}</span>],
|
||||
['最近检测', exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'],
|
||||
]} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Refine } from '@refinedev/core'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
@@ -9,7 +9,7 @@ afterEach(() => { cleanup(); vi.restoreAllMocks() })
|
||||
|
||||
const httpError = (message, status, body) => Object.assign(new Error(message), { status, body })
|
||||
|
||||
const networkExit = { id: 'exit-a', protocol: 'socks5', host: 'proxy.example', port: 1080, health_status: 'healthy', observed_public_ip: '203.0.113.1', credential_reference: { id: 'credential-a', provider: 'os_keyring' } }
|
||||
const networkExit = { id: 'exit-a', protocol: 'socks5', host: 'proxy.example', port: 1080, username: 'proxy-user', password: 'plain-password', health_status: 'healthy', observed_public_ip: '203.0.113.1' }
|
||||
|
||||
function provider(exits = [], overrides = {}) {
|
||||
return {
|
||||
@@ -35,16 +35,48 @@ describe('NetworkExitList', () => {
|
||||
expect(await screen.findByText(/创建并检测健康后/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows only credential references and runs an explicit health check', async () => {
|
||||
it('shows stored credentials and runs an explicit health check', async () => {
|
||||
const dataProvider = provider([networkExit])
|
||||
renderExits(dataProvider)
|
||||
|
||||
expect((await screen.findAllByText(/credential-a/)).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText(/password|token|raw-value/i)).toBeNull()
|
||||
expect((await screen.findAllByText(/proxy-user/)).length).toBeGreaterThan(0)
|
||||
expect((await screen.findAllByText(/plain-password/)).length).toBeGreaterThan(0)
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '检测' })[0])
|
||||
await waitFor(() => expect(dataProvider.networkExitAction).toHaveBeenCalledWith('exit-a', 'check'))
|
||||
})
|
||||
|
||||
it('creates with plain credentials and closes on success', async () => {
|
||||
const dataProvider = provider()
|
||||
renderExits(dataProvider)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '创建网络出口' }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '主机' }), { target: { value: 'proxy.example' } })
|
||||
fireEvent.change(within(dialog).getByRole('spinbutton', { name: '端口' }), { target: { value: '1080' } })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '用户名(可选)' }), { target: { value: 'proxy-user' } })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '密码(可选)' }), { target: { value: 'plain-password' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建网络出口' }))
|
||||
|
||||
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith({ resource: 'network-exits', variables: {
|
||||
protocol: 'socks5', host: 'proxy.example', port: 1080, username: 'proxy-user', password: 'plain-password', expected_public_ip: '', expected_region: '',
|
||||
} }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
})
|
||||
|
||||
it('keeps the create modal open after a failure', async () => {
|
||||
const dataProvider = provider([], { create: vi.fn().mockRejectedValue(new Error('创建失败')) })
|
||||
renderExits(dataProvider)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '创建网络出口' }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '主机' }), { target: { value: 'proxy.example' } })
|
||||
fireEvent.change(within(dialog).getByRole('spinbutton', { name: '端口' }), { target: { value: '1080' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建网络出口' }))
|
||||
|
||||
expect((await within(dialog).findByRole('alert')).textContent).toContain('创建失败')
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows unknown bindings and retries when browsers return 502', async () => {
|
||||
const dataProvider = provider([networkExit], {
|
||||
getList: vi.fn(({ resource }) => resource === 'browsers'
|
||||
|
||||
Reference in New Issue
Block a user