mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
fadc2b6c12
* fix(relay): authenticate endpoints and fail closed on missing routes * fix(tunnel): retain chains until receiver replacement is acknowledged * docs: record final relay security review and validation
478 lines
18 KiB
Plaintext
478 lines
18 KiB
Plaintext
package contract_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"go-backend/internal/auth"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestTunnelInsertMiddleHopRecordedRuntimeContract(t *testing.T) {
|
|
secret := "contract-jwt-secret"
|
|
router, r := setupContractRouter(t, secret)
|
|
server := httptest.NewServer(router)
|
|
defer server.Close()
|
|
|
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
|
if err != nil {
|
|
t.Fatalf("generate admin token: %v", err)
|
|
}
|
|
|
|
now := time.Now().UnixMilli()
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
|
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
|
`, now, now).Error; err != nil {
|
|
t.Fatalf("insert user: %v", err)
|
|
}
|
|
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
|
t.Fatalf("insert tunnel: %v", err)
|
|
}
|
|
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
|
|
|
|
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
|
t.Fatalf("insert node %s: %v", name, err)
|
|
}
|
|
return mustLastInsertID(t, r, name)
|
|
}
|
|
|
|
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
|
|
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
|
|
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
|
|
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
|
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
|
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
|
|
`, tunnelID, entryA).Error; err != nil {
|
|
t.Fatalf("insert initial entry chain_tunnel: %v", err)
|
|
}
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
|
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
|
|
`, tunnelID, exitNodeID).Error; err != nil {
|
|
t.Fatalf("insert exit chain_tunnel: %v", err)
|
|
}
|
|
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
|
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
|
`, tunnelID).Error; err != nil {
|
|
t.Fatalf("insert user_tunnel: %v", err)
|
|
}
|
|
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
|
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
|
`, tunnelID, now, now).Error; err != nil {
|
|
t.Fatalf("insert forward: %v", err)
|
|
}
|
|
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
|
|
|
|
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
|
|
t.Fatalf("insert forward_port: %v", err)
|
|
}
|
|
|
|
// This mock records control-plane commands only; it does not forward traffic.
|
|
type command struct {
|
|
node, kind string
|
|
data json.RawMessage
|
|
}
|
|
var mu sync.Mutex
|
|
var commands []command
|
|
for _, n := range []struct {
|
|
name, secret string
|
|
id int64
|
|
}{
|
|
{"A", "issue281-transition-entry-a-secret", entryA},
|
|
{"B", "issue281-transition-entry-b-secret", entryB},
|
|
{"C", "issue281-transition-entry-c-secret", entryC},
|
|
{"D", "issue281-transition-exit-secret", exitNodeID},
|
|
} {
|
|
name := n.name
|
|
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
|
|
return false, ""
|
|
})
|
|
defer stop()
|
|
waitNodeStatus(t, r, n.id, 1)
|
|
}
|
|
// Let initial mock-node reconnect reconciliation finish before the topology edit.
|
|
time.Sleep(time.Second)
|
|
node := func(id int64) map[string]interface{} {
|
|
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
|
|
}
|
|
update := func(hops [][]map[string]interface{}) {
|
|
mu.Lock()
|
|
commands = nil
|
|
mu.Unlock()
|
|
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
|
|
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
|
|
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
|
|
raw, _ := json.Marshal(payload)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
|
req.Header.Set("Authorization", adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
res := httptest.NewRecorder()
|
|
router.ServeHTTP(res, req)
|
|
assertCode(t, res, 0)
|
|
}
|
|
check := func(edges map[string]int64) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
|
|
got := map[string]bool{}
|
|
services := map[string]bool{}
|
|
for _, c := range commands {
|
|
if c.kind == "DeleteChains" {
|
|
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
|
|
}
|
|
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal(c.data, &raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
|
raw = nested
|
|
}
|
|
target, ok := edges[c.node]
|
|
if !ok {
|
|
t.Fatalf("unexpected chain owner %s", c.node)
|
|
}
|
|
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
|
|
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
|
|
hops := raw["hops"].([]interface{})
|
|
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
|
addr := nodes[0].(map[string]interface{})["addr"]
|
|
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
|
|
t.Fatalf("bad edge %s: %s", c.node, c.data)
|
|
}
|
|
got[c.node] = true
|
|
t.Logf("%s -> %s via %s", c.node, ip, addr)
|
|
}
|
|
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
|
|
var ss []map[string]interface{}
|
|
if err := json.Unmarshal(c.data, &ss); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, s := range ss {
|
|
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
|
|
continue
|
|
}
|
|
h := s["handler"].(map[string]interface{})
|
|
if c.node == "B" || c.node == "C" {
|
|
if h["chain"] != expectedChain {
|
|
t.Fatalf("missing chain on %s: %s", c.node, c.data)
|
|
}
|
|
}
|
|
if c.node == "D" && h["chain"] != nil {
|
|
t.Fatalf("exit has unexpected chain")
|
|
}
|
|
services[c.node] = true
|
|
}
|
|
}
|
|
}
|
|
for owner := range edges {
|
|
if !got[owner] {
|
|
t.Errorf("no chain update for %s", owner)
|
|
}
|
|
if owner != "A" && !services[owner] {
|
|
t.Errorf("no relay service for %s", owner)
|
|
}
|
|
}
|
|
if !services["D"] {
|
|
t.Error("no exit relay service")
|
|
}
|
|
}
|
|
update([][]map[string]interface{}{{node(entryB)}})
|
|
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
|
update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}})
|
|
check(map[string]int64{"A": entryB, "B": entryC, "C": exitNodeID})
|
|
// A remains the entry and the user's forward remains bound there.
|
|
after, _ := mustQueryInt64Int(t, r, `SELECT node_id,port FROM forward_port WHERE forward_id = ? LIMIT 1`, forwardID)
|
|
if after != entryA {
|
|
t.Fatalf("forward entry changed: %d", after)
|
|
}
|
|
}
|
|
|
|
func TestTunnelRedeployEditMissingChainWindowInvestigation(t *testing.T) {
|
|
secret := "contract-jwt-secret"
|
|
router, r := setupContractRouter(t, secret)
|
|
server := httptest.NewServer(router)
|
|
defer server.Close()
|
|
|
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
|
if err != nil {
|
|
t.Fatalf("generate admin token: %v", err)
|
|
}
|
|
|
|
now := time.Now().UnixMilli()
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
|
VALUES(2, 'issue281_transition_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
|
`, now, now).Error; err != nil {
|
|
t.Fatalf("insert user: %v", err)
|
|
}
|
|
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, "issue281-transition-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
|
t.Fatalf("insert tunnel: %v", err)
|
|
}
|
|
tunnelID := mustLastInsertID(t, r, "issue281-transition-tunnel")
|
|
|
|
insertNode := func(name, secretValue, ip, portRange string, inx int) int64 {
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, name, secretValue, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", inx).Error; err != nil {
|
|
t.Fatalf("insert node %s: %v", name, err)
|
|
}
|
|
return mustLastInsertID(t, r, name)
|
|
}
|
|
|
|
entryA := insertNode("issue281-transition-entry-a", "issue281-transition-entry-a-secret", "10.52.0.1", "54000-54010", 0)
|
|
entryB := insertNode("issue281-transition-entry-b", "issue281-transition-entry-b-secret", "10.52.0.2", "54000-54010", 1)
|
|
entryC := insertNode("issue281-transition-entry-c", "issue281-transition-entry-c-secret", "10.52.0.3", "54000-54010", 2)
|
|
exitNodeID := insertNode("issue281-transition-exit", "issue281-transition-exit-secret", "10.52.0.4", "57000-57010", 3)
|
|
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
|
VALUES(?, 1, ?, 54001, 'round', 1, 'tls')
|
|
`, tunnelID, entryA).Error; err != nil {
|
|
t.Fatalf("insert initial entry chain_tunnel: %v", err)
|
|
}
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
|
VALUES(?, 3, ?, 57001, 'round', 1, 'tls')
|
|
`, tunnelID, exitNodeID).Error; err != nil {
|
|
t.Fatalf("insert exit chain_tunnel: %v", err)
|
|
}
|
|
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
|
VALUES(282, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
|
|
`, tunnelID).Error; err != nil {
|
|
t.Fatalf("insert user_tunnel: %v", err)
|
|
}
|
|
|
|
if err := r.DB().Exec(`
|
|
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
|
VALUES(2, 'issue281_transition_user', 'issue281-transition-forward', ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
|
`, tunnelID, now, now).Error; err != nil {
|
|
t.Fatalf("insert forward: %v", err)
|
|
}
|
|
forwardID := mustLastInsertID(t, r, "issue281-transition-forward")
|
|
|
|
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, entryA, 54001).Error; err != nil {
|
|
t.Fatalf("insert forward_port: %v", err)
|
|
}
|
|
|
|
// This mock records control-plane commands only; it does not forward traffic.
|
|
type command struct {
|
|
node, kind string
|
|
data json.RawMessage
|
|
}
|
|
var mu sync.Mutex
|
|
var commands []command
|
|
armed := false
|
|
chainPresent, servicePresent := map[string]bool{}, map[string]bool{}
|
|
atDeleteService, releaseDeleteService, editReachedA := make(chan struct{}), make(chan struct{}), make(chan struct{}, 1)
|
|
var gateOnce, releaseOnce sync.Once
|
|
release := func() { releaseOnce.Do(func() { close(releaseDeleteService) }) }
|
|
defer release()
|
|
|
|
for _, n := range []struct {
|
|
name, secret string
|
|
id int64
|
|
}{
|
|
{"A", "issue281-transition-entry-a-secret", entryA},
|
|
{"B", "issue281-transition-entry-b-secret", entryB},
|
|
{"C", "issue281-transition-entry-c-secret", entryC},
|
|
{"D", "issue281-transition-exit-secret", exitNodeID},
|
|
} {
|
|
name := n.name
|
|
stop := startMockNodeSessionWithCommandRecorder(t, server.URL, n.secret, func(kind string, data json.RawMessage) (bool, string) {
|
|
mu.Lock()
|
|
active := armed
|
|
shouldGate := active && name == "B" && kind == "DeleteService"
|
|
if active && name == "A" && kind == "UpdateChains" {
|
|
select {
|
|
case editReachedA <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
mu.Unlock()
|
|
if shouldGate {
|
|
gateOnce.Do(func() { close(atDeleteService); <-releaseDeleteService })
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if kind == "DeleteChains" {
|
|
chainPresent[name] = false
|
|
}
|
|
if kind == "AddChains" || kind == "UpdateChains" {
|
|
chainPresent[name] = true
|
|
}
|
|
if kind == "DeleteService" {
|
|
servicePresent[name] = false
|
|
}
|
|
if kind == "AddService" || kind == "UpdateService" {
|
|
servicePresent[name] = true
|
|
}
|
|
commands = append(commands, command{name, kind, append(json.RawMessage(nil), data...)})
|
|
return false, ""
|
|
})
|
|
defer stop()
|
|
waitNodeStatus(t, r, n.id, 1)
|
|
}
|
|
// Let initial mock-node reconnect reconciliation finish before the topology edit.
|
|
time.Sleep(time.Second)
|
|
node := func(id int64) map[string]interface{} {
|
|
return map[string]interface{}{"nodeId": id, "protocol": "tls", "strategy": "round"}
|
|
}
|
|
update := func(hops [][]map[string]interface{}) {
|
|
mu.Lock()
|
|
commands = nil
|
|
mu.Unlock()
|
|
payload := map[string]interface{}{"id": tunnelID, "name": "issue281-transition-tunnel", "type": 2, "flow": 99999, "trafficRatio": 1.0, "status": 1,
|
|
"inNodeId": []map[string]interface{}{node(entryA)}, "chainNodes": hops,
|
|
"outNodeId": []map[string]interface{}{{"nodeId": exitNodeID, "protocol": "tls", "strategy": "round", "port": 57001}}}
|
|
raw, _ := json.Marshal(payload)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", bytes.NewReader(raw))
|
|
req.Header.Set("Authorization", adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
res := httptest.NewRecorder()
|
|
router.ServeHTTP(res, req)
|
|
assertCode(t, res, 0)
|
|
}
|
|
check := func(edges map[string]int64) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
expectedChain := fmt.Sprintf("chains_%d", tunnelID)
|
|
got := map[string]bool{}
|
|
services := map[string]bool{}
|
|
for _, c := range commands {
|
|
if c.kind == "DeleteChains" {
|
|
t.Fatalf("unexpected chain deletion on %s: %s", c.node, c.data)
|
|
}
|
|
if c.kind == "UpdateChains" || c.kind == "AddChains" {
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal(c.data, &raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if nested, ok := raw["data"].(map[string]interface{}); ok {
|
|
raw = nested
|
|
}
|
|
target, ok := edges[c.node]
|
|
if !ok {
|
|
t.Fatalf("unexpected chain owner %s", c.node)
|
|
}
|
|
port := mustQueryInt(t, r, `SELECT port FROM chain_tunnel WHERE tunnel_id = ? AND node_id = ? LIMIT 1`, tunnelID, target)
|
|
ip := map[int64]string{entryB: "10.52.0.2", entryC: "10.52.0.3", exitNodeID: "10.52.0.4"}[target]
|
|
hops := raw["hops"].([]interface{})
|
|
nodes := hops[0].(map[string]interface{})["nodes"].([]interface{})
|
|
addr := nodes[0].(map[string]interface{})["addr"]
|
|
if len(hops) != 1 || len(nodes) != 1 || addr != fmt.Sprintf("%s:%d", ip, port) {
|
|
t.Fatalf("bad edge %s: %s", c.node, c.data)
|
|
}
|
|
got[c.node] = true
|
|
t.Logf("%s -> %s via %s", c.node, ip, addr)
|
|
}
|
|
if strings.EqualFold(c.kind, "UpdateService") || strings.EqualFold(c.kind, "AddService") {
|
|
var ss []map[string]interface{}
|
|
if err := json.Unmarshal(c.data, &ss); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, s := range ss {
|
|
if s["name"] != fmt.Sprintf("tunnel_%d", tunnelID) {
|
|
continue
|
|
}
|
|
h := s["handler"].(map[string]interface{})
|
|
if c.node == "B" || c.node == "C" {
|
|
if h["chain"] != expectedChain {
|
|
t.Fatalf("missing chain on %s: %s", c.node, c.data)
|
|
}
|
|
}
|
|
if c.node == "D" && h["chain"] != nil {
|
|
t.Fatalf("exit has unexpected chain")
|
|
}
|
|
services[c.node] = true
|
|
}
|
|
}
|
|
}
|
|
for owner := range edges {
|
|
if !got[owner] {
|
|
t.Errorf("no chain update for %s", owner)
|
|
}
|
|
if owner != "A" && !services[owner] {
|
|
t.Errorf("no relay service for %s", owner)
|
|
}
|
|
}
|
|
if !services["D"] {
|
|
t.Error("no exit relay service")
|
|
}
|
|
}
|
|
update([][]map[string]interface{}{{node(entryB)}})
|
|
check(map[string]int64{"A": entryB, "B": exitNodeID})
|
|
|
|
mu.Lock()
|
|
armed = true
|
|
mu.Unlock()
|
|
redeployDone := make(chan struct{})
|
|
go func() {
|
|
defer close(redeployDone)
|
|
raw, _ := json.Marshal(map[string]interface{}{"ids": []int64{tunnelID}})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewReader(raw))
|
|
req.Header.Set("Authorization", adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
res := httptest.NewRecorder()
|
|
router.ServeHTTP(res, req)
|
|
t.Logf("redeploy response: %s", res.Body.String())
|
|
}()
|
|
await := func(ch <-chan struct{}, label string) {
|
|
t.Helper()
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(10 * time.Second):
|
|
release()
|
|
t.Fatalf("timeout waiting for %s", label)
|
|
}
|
|
}
|
|
await(atDeleteService, "B DeleteService gate")
|
|
editDone := make(chan struct{})
|
|
go func() { defer close(editDone); update([][]map[string]interface{}{{node(entryB)}, {node(entryC)}}) }()
|
|
await(editReachedA, "edit UpdateChains on A")
|
|
mu.Lock()
|
|
missing, live := !chainPresent["B"], servicePresent["B"]
|
|
mu.Unlock()
|
|
if !missing || !live {
|
|
release()
|
|
t.Fatalf("expected B live service with missing chain during overlap, missing=%v live=%v", missing, live)
|
|
}
|
|
t.Log("Deterministic overlap confirmed: edit reached A while redeploy had deleted B chain but had not deleted B relay service")
|
|
release()
|
|
await(editDone, "edit completion")
|
|
await(redeployDone, "redeploy completion")
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
t.Logf("final mock runtime chain presence: A=%v B=%v C=%v; services B=%v C=%v D=%v", chainPresent["A"], chainPresent["B"], chainPresent["C"], servicePresent["B"], servicePresent["C"], servicePresent["D"])
|
|
}
|