1192 lines
34 KiB
Go
1192 lines
34 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
transporttoken "selective-vpn-api/app/transporttoken"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestValidateTransportPolicyOwnershipConflict(t *testing.T) {
|
|
clients := []TransportClient{
|
|
{ID: "c1", Kind: TransportClientSingBox},
|
|
{ID: "c2", Kind: TransportClientDNSTT},
|
|
}
|
|
next := []TransportPolicyIntent{
|
|
{SelectorType: "domain", SelectorValue: "example.com", ClientID: "c1"},
|
|
{SelectorType: "domain", SelectorValue: "example.com", ClientID: "c2"},
|
|
}
|
|
res := validateTransportPolicy(next, nil, clients)
|
|
if res.Valid {
|
|
t.Fatalf("expected invalid policy")
|
|
}
|
|
if res.Summary.BlockCount == 0 {
|
|
t.Fatalf("expected blocking conflicts")
|
|
}
|
|
found := false
|
|
for _, c := range res.Conflicts {
|
|
if c.Type == "ownership" && strings.Contains(c.Key, "domain:example.com") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("ownership conflict not found: %#v", res.Conflicts)
|
|
}
|
|
}
|
|
|
|
func TestValidateTransportPolicyCIDROverlap(t *testing.T) {
|
|
clients := []TransportClient{
|
|
{ID: "c1", Kind: TransportClientSingBox},
|
|
{ID: "c2", Kind: TransportClientPhoenix},
|
|
}
|
|
next := []TransportPolicyIntent{
|
|
{SelectorType: "cidr", SelectorValue: "10.0.0.0/24", ClientID: "c1"},
|
|
{SelectorType: "cidr", SelectorValue: "10.0.0.128/25", ClientID: "c2"},
|
|
}
|
|
res := validateTransportPolicy(next, nil, clients)
|
|
if res.Summary.BlockCount == 0 {
|
|
t.Fatalf("expected CIDR overlap block conflict")
|
|
}
|
|
found := false
|
|
for _, c := range res.Conflicts {
|
|
if c.Type == "cidr_overlap" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("cidr overlap conflict not found: %#v", res.Conflicts)
|
|
}
|
|
}
|
|
|
|
func TestCompileTransportPolicyPlanGroupsByIface(t *testing.T) {
|
|
clients := []TransportClient{
|
|
{
|
|
ID: "c1",
|
|
Kind: TransportClientSingBox,
|
|
IfaceID: "edge-a",
|
|
MarkHex: "0x120",
|
|
PriorityBase: 13300,
|
|
},
|
|
{
|
|
ID: "c2",
|
|
Kind: TransportClientDNSTT,
|
|
IfaceID: "edge-b",
|
|
MarkHex: "0x121",
|
|
PriorityBase: 13350,
|
|
},
|
|
}
|
|
intents := []TransportPolicyIntent{
|
|
{SelectorType: "domain", SelectorValue: "a.example", ClientID: "c1", Mode: "strict", Priority: 100},
|
|
{SelectorType: "cidr", SelectorValue: "10.1.0.0/24", ClientID: "c1", Mode: "strict", Priority: 90},
|
|
{SelectorType: "domain", SelectorValue: "b.example", ClientID: "c2", Mode: "fallback", Priority: 80},
|
|
}
|
|
plan, conflicts := compileTransportPolicyPlan(intents, clients, 7)
|
|
if len(conflicts) != 0 {
|
|
t.Fatalf("unexpected compile conflicts: %#v", conflicts)
|
|
}
|
|
if plan.PolicyRevision != 7 {
|
|
t.Fatalf("unexpected policy revision: %d", plan.PolicyRevision)
|
|
}
|
|
if plan.InterfaceCount != 2 {
|
|
t.Fatalf("unexpected interface count: %d", plan.InterfaceCount)
|
|
}
|
|
if plan.RuleCount != 3 {
|
|
t.Fatalf("unexpected rule count: %d", plan.RuleCount)
|
|
}
|
|
|
|
var edgeA *TransportPolicyCompileInterface
|
|
for i := range plan.Interfaces {
|
|
if plan.Interfaces[i].IfaceID == "edge-a" {
|
|
edgeA = &plan.Interfaces[i]
|
|
break
|
|
}
|
|
}
|
|
if edgeA == nil {
|
|
t.Fatalf("edge-a plan not found: %#v", plan.Interfaces)
|
|
}
|
|
if edgeA.RoutingTable != "agvpn_if_edge_a" {
|
|
t.Fatalf("unexpected edge-a routing table: %q", edgeA.RoutingTable)
|
|
}
|
|
if edgeA.RuleCount != 2 {
|
|
t.Fatalf("unexpected edge-a rule count: %d", edgeA.RuleCount)
|
|
}
|
|
if len(edgeA.Sets) != 2 {
|
|
t.Fatalf("unexpected edge-a sets: %#v", edgeA.Sets)
|
|
}
|
|
}
|
|
|
|
func TestCompileTransportPolicyPlanUsesOwnerScopedNftSets(t *testing.T) {
|
|
clients := []TransportClient{
|
|
{
|
|
ID: "c1",
|
|
Kind: TransportClientSingBox,
|
|
IfaceID: "edge-a",
|
|
MarkHex: "0x120",
|
|
PriorityBase: 13300,
|
|
},
|
|
{
|
|
ID: "c2",
|
|
Kind: TransportClientDNSTT,
|
|
IfaceID: "edge-a",
|
|
MarkHex: "0x121",
|
|
PriorityBase: 13350,
|
|
},
|
|
}
|
|
intents := []TransportPolicyIntent{
|
|
{SelectorType: "cidr", SelectorValue: "10.10.0.0/24", ClientID: "c1"},
|
|
{SelectorType: "cidr", SelectorValue: "10.20.0.0/24", ClientID: "c2"},
|
|
}
|
|
plan, conflicts := compileTransportPolicyPlan(intents, clients, 9)
|
|
if len(conflicts) != 0 {
|
|
t.Fatalf("unexpected compile conflicts: %#v", conflicts)
|
|
}
|
|
if plan.InterfaceCount != 1 || len(plan.Interfaces) != 1 {
|
|
t.Fatalf("unexpected interface shape: %#v", plan.Interfaces)
|
|
}
|
|
iface := plan.Interfaces[0]
|
|
if len(iface.Sets) != 2 {
|
|
t.Fatalf("expected 2 owner-scoped sets for same iface, got %#v", iface.Sets)
|
|
}
|
|
setByScope := map[string]string{}
|
|
for _, s := range iface.Sets {
|
|
if s.SelectorType != "cidr" {
|
|
continue
|
|
}
|
|
setByScope[s.OwnerScope] = s.Name
|
|
}
|
|
if len(setByScope) != 2 {
|
|
t.Fatalf("expected 2 cidr owner scopes, got %#v", setByScope)
|
|
}
|
|
if setByScope["edge_a_c1"] == "" || setByScope["edge_a_c2"] == "" {
|
|
t.Fatalf("unexpected owner scopes: %#v", setByScope)
|
|
}
|
|
if setByScope["edge_a_c1"] == setByScope["edge_a_c2"] {
|
|
t.Fatalf("owner-scoped set names must differ: %#v", setByScope)
|
|
}
|
|
for _, r := range iface.Rules {
|
|
if strings.TrimSpace(r.OwnerScope) == "" {
|
|
t.Fatalf("owner_scope must be filled for rule: %#v", r)
|
|
}
|
|
if strings.TrimSpace(r.NftSet) == "" {
|
|
t.Fatalf("nft_set must be filled for rule: %#v", r)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTransportPolicyNftSetNameDeterministicAndBounded(t *testing.T) {
|
|
scope := transportPolicyNftOwnerScope("very-very-long-interface-name-for-testing", "very-very-long-client-name-for-testing")
|
|
if scope == "" {
|
|
t.Fatalf("owner scope must not be empty")
|
|
}
|
|
setA := transportPolicyNftSetName(scope, "cidr")
|
|
setB := transportPolicyNftSetName(scope, "cidr")
|
|
if setA != setB {
|
|
t.Fatalf("set name must be deterministic: %q vs %q", setA, setB)
|
|
}
|
|
if len(setA) == 0 || len(setA) > 63 {
|
|
t.Fatalf("unexpected set name length %d: %q", len(setA), setA)
|
|
}
|
|
if !strings.HasPrefix(setA, "agvpn_pi_") {
|
|
t.Fatalf("unexpected set name prefix: %q", setA)
|
|
}
|
|
}
|
|
|
|
func TestCompileTransportPolicyPlanDetectsAllocatorCollision(t *testing.T) {
|
|
clients := []TransportClient{
|
|
{
|
|
ID: "c1",
|
|
Kind: TransportClientSingBox,
|
|
IfaceID: "edge-a",
|
|
MarkHex: "0x120",
|
|
PriorityBase: 13300,
|
|
},
|
|
{
|
|
ID: "c2",
|
|
Kind: TransportClientDNSTT,
|
|
IfaceID: "edge-b",
|
|
MarkHex: "0x120",
|
|
PriorityBase: 13300,
|
|
},
|
|
}
|
|
intents := []TransportPolicyIntent{
|
|
{SelectorType: "domain", SelectorValue: "a.example", ClientID: "c1"},
|
|
{SelectorType: "domain", SelectorValue: "b.example", ClientID: "c2"},
|
|
}
|
|
_, conflicts := compileTransportPolicyPlan(intents, clients, 8)
|
|
found := false
|
|
for _, c := range conflicts {
|
|
if c.Type == "allocator_collision" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected allocator_collision, got: %#v", conflicts)
|
|
}
|
|
}
|
|
|
|
func TestTransportConfirmTokenLifecycle(t *testing.T) {
|
|
transportConfirmStore = transporttoken.NewStore(transportConfirmTTL)
|
|
|
|
token := issueTransportConfirmToken(7, "digest-a")
|
|
if token == "" {
|
|
t.Fatalf("empty token")
|
|
}
|
|
if !consumeTransportConfirmToken(token, 7, "digest-a") {
|
|
t.Fatalf("expected token to be consumed")
|
|
}
|
|
if consumeTransportConfirmToken(token, 7, "digest-a") {
|
|
t.Fatalf("token must be single-use")
|
|
}
|
|
}
|
|
|
|
func TestTransportConfirmStoreExpiresToken(t *testing.T) {
|
|
store := transporttoken.NewStore(50 * time.Millisecond)
|
|
token := store.Issue("cnf-", 1, "digest-b")
|
|
if token == "" {
|
|
t.Fatalf("empty token")
|
|
}
|
|
time.Sleep(80 * time.Millisecond)
|
|
if store.Consume(token, 1, "digest-b") {
|
|
t.Fatalf("expired token should not be consumed")
|
|
}
|
|
}
|
|
|
|
func withTransportLifecycleTestPaths(t *testing.T) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
|
|
prevClients := transportClientsStatePath
|
|
prevIfaces := transportInterfacesStatePath
|
|
prevSingBoxState := singBoxProfilesStatePath
|
|
prevSingBoxRendered := singBoxRenderedRootDir
|
|
prevSingBoxApplied := singBoxAppliedRootDir
|
|
|
|
transportClientsStatePath = filepath.Join(tmp, "transport-clients.json")
|
|
transportInterfacesStatePath = filepath.Join(tmp, "transport-interfaces.json")
|
|
singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json")
|
|
singBoxRenderedRootDir = filepath.Join(tmp, "transport", "singbox-rendered")
|
|
singBoxAppliedRootDir = filepath.Join(tmp, "transport", "singbox-applied")
|
|
|
|
t.Cleanup(func() {
|
|
transportClientsStatePath = prevClients
|
|
transportInterfacesStatePath = prevIfaces
|
|
singBoxProfilesStatePath = prevSingBoxState
|
|
singBoxRenderedRootDir = prevSingBoxRendered
|
|
singBoxAppliedRootDir = prevSingBoxApplied
|
|
})
|
|
}
|
|
|
|
func saveLinkedSingBoxProfile(t *testing.T, clientID string) {
|
|
t.Helper()
|
|
|
|
st := loadSingBoxProfilesState()
|
|
profile := SingBoxProfile{
|
|
ID: sanitizeID(clientID),
|
|
Name: clientID,
|
|
Mode: SingBoxProfileModeRaw,
|
|
Protocol: "vless",
|
|
Enabled: true,
|
|
SchemaVersion: 1,
|
|
RawConfig: map[string]any{
|
|
"inbounds": []any{
|
|
map[string]any{
|
|
"type": "socks",
|
|
"tag": "socks-in",
|
|
"listen": "127.0.0.1",
|
|
"listen_port": 10808,
|
|
},
|
|
},
|
|
"outbounds": []any{
|
|
map[string]any{
|
|
"type": "vless",
|
|
"tag": "proxy",
|
|
"server": "example.com",
|
|
"server_port": 443,
|
|
"uuid": "11111111-1111-1111-1111-111111111111",
|
|
"packet_encoding": "none",
|
|
},
|
|
},
|
|
},
|
|
Meta: map[string]any{
|
|
"client_id": sanitizeID(clientID),
|
|
},
|
|
ProfileRevision: 1,
|
|
}
|
|
st.Items = append(st.Items, profile)
|
|
st.Revision++
|
|
if err := saveSingBoxProfilesState(st); err != nil {
|
|
t.Fatalf("save singbox profile state: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTransportClientsStateDeterministicRebalance(t *testing.T) {
|
|
st := transportClientsState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportClient{
|
|
{
|
|
ID: "Client-B",
|
|
Kind: TransportClientPhoenix,
|
|
MarkHex: "0x110",
|
|
PriorityBase: 13250,
|
|
},
|
|
{
|
|
ID: "client-a",
|
|
Kind: TransportClientDNSTT,
|
|
MarkHex: "0x110", // duplicate mark -> should force rebalance
|
|
PriorityBase: 13250, // duplicate pref -> should force rebalance
|
|
},
|
|
{
|
|
ID: "client-c",
|
|
Kind: TransportClientSingBox,
|
|
MarkHex: "",
|
|
PriorityBase: 0,
|
|
},
|
|
},
|
|
}
|
|
|
|
norm, changed := normalizeTransportClientsState(st, false)
|
|
if !changed {
|
|
t.Fatalf("expected normalization changes")
|
|
}
|
|
if len(norm.Items) != 3 {
|
|
t.Fatalf("unexpected item count: %d", len(norm.Items))
|
|
}
|
|
|
|
seenMarks := map[string]struct{}{}
|
|
seenPrefs := map[int]struct{}{}
|
|
for i, it := range norm.Items {
|
|
if i > 0 && norm.Items[i-1].ID > it.ID {
|
|
t.Fatalf("items not sorted by id")
|
|
}
|
|
if _, ok := parseTransportMarkHex(it.MarkHex); !ok {
|
|
t.Fatalf("invalid mark after normalize: %q", it.MarkHex)
|
|
}
|
|
if _, exists := seenMarks[it.MarkHex]; exists {
|
|
t.Fatalf("duplicate mark after normalize: %q", it.MarkHex)
|
|
}
|
|
seenMarks[it.MarkHex] = struct{}{}
|
|
|
|
if _, ok := parseTransportPref(it.PriorityBase); !ok {
|
|
t.Fatalf("invalid pref after normalize: %d", it.PriorityBase)
|
|
}
|
|
if _, exists := seenPrefs[it.PriorityBase]; exists {
|
|
t.Fatalf("duplicate pref after normalize: %d", it.PriorityBase)
|
|
}
|
|
seenPrefs[it.PriorityBase] = struct{}{}
|
|
}
|
|
|
|
norm2, changed2 := normalizeTransportClientsState(norm, false)
|
|
if changed2 {
|
|
t.Fatalf("expected stable deterministic state without changes")
|
|
}
|
|
if len(norm2.Items) != len(norm.Items) {
|
|
t.Fatalf("unexpected length after second normalize")
|
|
}
|
|
for i := range norm.Items {
|
|
if norm.Items[i].ID != norm2.Items[i].ID ||
|
|
norm.Items[i].MarkHex != norm2.Items[i].MarkHex ||
|
|
norm.Items[i].PriorityBase != norm2.Items[i].PriorityBase ||
|
|
norm.Items[i].RoutingTable != norm2.Items[i].RoutingTable {
|
|
t.Fatalf("state not deterministic at index %d: %#v != %#v", i, norm.Items[i], norm2.Items[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAllocateTransportSlotsSkipsReservedRanges(t *testing.T) {
|
|
items := make([]TransportClient, 0, 3)
|
|
for i := 0; i < 3; i++ {
|
|
mark, pref := allocateTransportSlots(items)
|
|
items = append(items, TransportClient{
|
|
ID: fmt.Sprintf("c-%d", i),
|
|
Kind: TransportClientSingBox,
|
|
MarkHex: mark,
|
|
PriorityBase: pref,
|
|
})
|
|
}
|
|
|
|
for _, it := range items {
|
|
m, ok := parseTransportMarkHex(it.MarkHex)
|
|
if !ok {
|
|
t.Fatalf("allocated invalid mark %q", it.MarkHex)
|
|
}
|
|
if m <= transportMarkReserveEnd {
|
|
t.Fatalf("allocator used reserved mark: %s", it.MarkHex)
|
|
}
|
|
if !transportPrefAllowed(it.PriorityBase) {
|
|
t.Fatalf("allocated invalid pref %d", it.PriorityBase)
|
|
}
|
|
if it.PriorityBase <= transportPrefReserveEnd {
|
|
t.Fatalf("allocator used reserved pref: %d", it.PriorityBase)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTransportRoutingTableUniqueOnLongIDs(t *testing.T) {
|
|
st := transportClientsState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportClient{
|
|
{ID: "client-super-long-identifier-aaaaaaaaaaaa-1", Kind: TransportClientSingBox},
|
|
{ID: "client-super-long-identifier-aaaaaaaaaaaa-2", Kind: TransportClientDNSTT},
|
|
},
|
|
}
|
|
norm, _ := normalizeTransportClientsState(st, false)
|
|
if len(norm.Items) != 2 {
|
|
t.Fatalf("unexpected count: %d", len(norm.Items))
|
|
}
|
|
a := norm.Items[0].RoutingTable
|
|
b := norm.Items[1].RoutingTable
|
|
if a == "" || b == "" {
|
|
t.Fatalf("empty routing table values")
|
|
}
|
|
if a == b {
|
|
t.Fatalf("routing tables must be unique: %q", a)
|
|
}
|
|
if len(a) > 31 || len(b) > 31 {
|
|
t.Fatalf("routing table name exceeds 31 chars: %q / %q", a, b)
|
|
}
|
|
}
|
|
|
|
func TestApplyTransportLifecycleActionMetrics(t *testing.T) {
|
|
base := time.Date(2026, time.March, 7, 12, 0, 0, 0, time.UTC)
|
|
c := TransportClient{
|
|
ID: "c1",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientDown,
|
|
Enabled: false,
|
|
}
|
|
|
|
applyTransportLifecycleAction(&c, "start", base)
|
|
if c.Status != TransportClientUp {
|
|
t.Fatalf("expected up after start, got %s", c.Status)
|
|
}
|
|
if !c.Enabled {
|
|
t.Fatalf("start must enable client")
|
|
}
|
|
if c.Runtime.Backend != "mock" {
|
|
t.Fatalf("unexpected backend: %q", c.Runtime.Backend)
|
|
}
|
|
if c.Runtime.Metrics.StateChanges != 1 {
|
|
t.Fatalf("expected state_changes=1, got %d", c.Runtime.Metrics.StateChanges)
|
|
}
|
|
if c.Runtime.StartedAt == "" {
|
|
t.Fatalf("started_at must be set on start")
|
|
}
|
|
|
|
applyTransportLifecycleAction(&c, "restart", base.Add(2*time.Second))
|
|
if c.Runtime.Metrics.Restarts != 1 {
|
|
t.Fatalf("expected restarts=1, got %d", c.Runtime.Metrics.Restarts)
|
|
}
|
|
if c.Runtime.Metrics.StateChanges != 1 {
|
|
t.Fatalf("restart on up should not change state counter, got %d", c.Runtime.Metrics.StateChanges)
|
|
}
|
|
|
|
applyTransportLifecycleAction(&c, "stop", base.Add(4*time.Second))
|
|
if c.Status != TransportClientDown {
|
|
t.Fatalf("expected down after stop, got %s", c.Status)
|
|
}
|
|
if c.Runtime.Metrics.StateChanges != 2 {
|
|
t.Fatalf("expected state_changes=2 after stop, got %d", c.Runtime.Metrics.StateChanges)
|
|
}
|
|
if c.Runtime.StoppedAt == "" {
|
|
t.Fatalf("stopped_at must be set on stop")
|
|
}
|
|
if c.Runtime.Metrics.UptimeSec != 0 {
|
|
t.Fatalf("uptime must be reset on down status, got %d", c.Runtime.Metrics.UptimeSec)
|
|
}
|
|
}
|
|
|
|
func TestBuildTransportHealthResponseDegradedCode(t *testing.T) {
|
|
base := time.Date(2026, time.March, 7, 12, 0, 0, 0, time.UTC)
|
|
c := TransportClient{
|
|
ID: "c1",
|
|
Kind: TransportClientPhoenix,
|
|
Status: TransportClientDegraded,
|
|
Health: TransportClientHealth{
|
|
LastCheck: base.Format(time.RFC3339),
|
|
LastError: "timeout",
|
|
},
|
|
}
|
|
resp := buildTransportHealthResponse(c, base.Add(5*time.Second))
|
|
if !resp.OK {
|
|
t.Fatalf("expected ok response")
|
|
}
|
|
if resp.Code != "TRANSPORT_CLIENT_DEGRADED" {
|
|
t.Fatalf("expected degraded code, got %q", resp.Code)
|
|
}
|
|
if strings.TrimSpace(resp.Runtime.Backend) == "" {
|
|
t.Fatalf("expected runtime backend to be present")
|
|
}
|
|
if resp.LastErr == "" {
|
|
t.Fatalf("expected last_error to be present")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTransportRuntimeStoredDefaults(t *testing.T) {
|
|
raw := TransportClientRuntime{
|
|
LastExitCode: -10,
|
|
Metrics: TransportClientMetrics{
|
|
Restarts: -2,
|
|
StateChanges: -3,
|
|
UptimeSec: -4,
|
|
},
|
|
LastError: TransportClientError{
|
|
Code: "X",
|
|
},
|
|
}
|
|
norm, changed := normalizeTransportRuntimeStored(raw, TransportClientDNSTT, nil)
|
|
if !changed {
|
|
t.Fatalf("expected runtime normalization changes")
|
|
}
|
|
if norm.Backend != "mock" {
|
|
t.Fatalf("backend not normalized: %q", norm.Backend)
|
|
}
|
|
if !equalStringSlices(norm.AllowedActions, []string{"provision", "start", "stop", "restart"}) {
|
|
t.Fatalf("allowed_actions not normalized: %#v", norm.AllowedActions)
|
|
}
|
|
if norm.Metrics.Restarts != 0 || norm.Metrics.StateChanges != 0 || norm.Metrics.UptimeSec != 0 {
|
|
t.Fatalf("metrics must be clamped to zero: %#v", norm.Metrics)
|
|
}
|
|
if norm.LastExitCode != 0 {
|
|
t.Fatalf("last_exit_code must be clamped to zero, got %d", norm.LastExitCode)
|
|
}
|
|
if norm.LastError.Code != "" {
|
|
t.Fatalf("orphan error code must be cleared, got %q", norm.LastError.Code)
|
|
}
|
|
}
|
|
|
|
func TestApplyTransportProvisionResultFailure(t *testing.T) {
|
|
base := time.Date(2026, time.March, 7, 12, 0, 0, 0, time.UTC)
|
|
c := TransportClient{
|
|
ID: "c1",
|
|
Kind: TransportClientDNSTT,
|
|
Status: TransportClientDown,
|
|
}
|
|
applyTransportProvisionResult(&c, base, "systemd", transportBackendActionResult{
|
|
OK: false,
|
|
Code: "TRANSPORT_BACKEND_PROVISION_FAILED",
|
|
Message: "write unit failed",
|
|
ExitCode: 1,
|
|
Retryable: true,
|
|
})
|
|
if c.Runtime.LastAction != "provision" {
|
|
t.Fatalf("unexpected last action: %q", c.Runtime.LastAction)
|
|
}
|
|
if c.Runtime.Backend != "systemd" {
|
|
t.Fatalf("unexpected backend: %q", c.Runtime.Backend)
|
|
}
|
|
if c.Runtime.LastError.Code != "TRANSPORT_BACKEND_PROVISION_FAILED" {
|
|
t.Fatalf("unexpected last error: %#v", c.Runtime.LastError)
|
|
}
|
|
if c.Health.LastError == "" {
|
|
t.Fatalf("health last_error must be set on provision failure")
|
|
}
|
|
}
|
|
|
|
func TestHandleTransportCapabilitiesRuntimeModes(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/transport/capabilities", nil)
|
|
rec := httptest.NewRecorder()
|
|
handleTransportCapabilities(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("unexpected status: %d", rec.Code)
|
|
}
|
|
var resp TransportCapabilitiesResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !resp.OK {
|
|
t.Fatalf("expected ok capabilities response: %#v", resp)
|
|
}
|
|
if !resp.RuntimeModes["exec"] {
|
|
t.Fatalf("runtime_modes.exec must be true")
|
|
}
|
|
if resp.RuntimeModes["embedded"] {
|
|
t.Fatalf("runtime_modes.embedded must be false until embedded backend is implemented")
|
|
}
|
|
if resp.RuntimeModes["sidecar"] {
|
|
t.Fatalf("runtime_modes.sidecar must be false until sidecar backend is implemented")
|
|
}
|
|
if !resp.PackagingProfiles["system"] || !resp.PackagingProfiles["bundled"] {
|
|
t.Fatalf("packaging_profiles must advertise system and bundled support: %#v", resp.PackagingProfiles)
|
|
}
|
|
found := false
|
|
for _, code := range resp.ErrorCodes {
|
|
if code == "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("runtime-mode error code not advertised: %#v", resp.ErrorCodes)
|
|
}
|
|
}
|
|
|
|
func TestApplyTransportNetnsToggleLockedSuccess(t *testing.T) {
|
|
withTransportLifecycleTestPaths(t)
|
|
|
|
now := time.Date(2026, time.March, 9, 20, 0, 0, 0, time.UTC)
|
|
configPath := filepath.Join(t.TempDir(), "sb-one.json")
|
|
if err := saveTransportClientsState(transportClientsState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportClient{
|
|
{
|
|
ID: "sb-one",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": false,
|
|
"config_path": configPath,
|
|
"singbox_preflight_check_binary": false,
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save clients state: %v", err)
|
|
}
|
|
saveLinkedSingBoxProfile(t, "sb-one")
|
|
enable := true
|
|
provision := true
|
|
restart := true
|
|
resp := executeTransportNetnsToggleLocked(TransportNetnsToggleRequest{
|
|
Enabled: &enable,
|
|
ClientIDs: []string{"sb-one"},
|
|
Provision: &provision,
|
|
RestartRunning: &restart,
|
|
}, now)
|
|
|
|
if !resp.OK {
|
|
t.Fatalf("expected ok response, got %#v", resp)
|
|
}
|
|
if !resp.Enabled {
|
|
t.Fatalf("enabled flag must be true")
|
|
}
|
|
if resp.Count != 1 || resp.SuccessCount != 1 || resp.FailureCount != 0 {
|
|
t.Fatalf("unexpected counters: %#v", resp)
|
|
}
|
|
if len(resp.Items) != 1 {
|
|
t.Fatalf("expected one item, got %d", len(resp.Items))
|
|
}
|
|
item := resp.Items[0]
|
|
if !item.OK {
|
|
t.Fatalf("expected successful item: %#v", item)
|
|
}
|
|
if !item.ConfigUpdated || !item.Provisioned || !item.Restarted {
|
|
t.Fatalf("expected config+provision+restart to be applied: %#v", item)
|
|
}
|
|
if item.StatusBefore != TransportClientUp || item.StatusAfter != TransportClientUp {
|
|
t.Fatalf("unexpected status transition: %#v", item)
|
|
}
|
|
current := loadTransportClientsState()
|
|
cfg := current.Items[0].Config
|
|
if !transportConfigBool(cfg, "netns_enabled") {
|
|
t.Fatalf("state config netns_enabled not updated: %#v", cfg)
|
|
}
|
|
if got := strings.TrimSpace(transportConfigString(cfg, "netns_name")); got != "svpn-sb-one" {
|
|
t.Fatalf("unexpected netns_name: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyTransportNetnsToggleLockedPartialFailure(t *testing.T) {
|
|
withTransportLifecycleTestPaths(t)
|
|
|
|
now := time.Date(2026, time.March, 9, 20, 5, 0, 0, time.UTC)
|
|
if err := saveTransportClientsState(transportClientsState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportClient{
|
|
{
|
|
ID: "sb-one",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientDown,
|
|
Enabled: true,
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save clients state: %v", err)
|
|
}
|
|
enable := true
|
|
provision := false
|
|
resp := executeTransportNetnsToggleLocked(TransportNetnsToggleRequest{
|
|
Enabled: &enable,
|
|
ClientIDs: []string{"sb-one", "missing-id"},
|
|
Provision: &provision,
|
|
}, now)
|
|
|
|
if resp.OK {
|
|
t.Fatalf("expected partial failure response")
|
|
}
|
|
if resp.Code != "TRANSPORT_NETNS_TOGGLE_PARTIAL_FAILED" {
|
|
t.Fatalf("unexpected code: %q", resp.Code)
|
|
}
|
|
if resp.Count != 2 || resp.SuccessCount != 1 || resp.FailureCount != 1 {
|
|
t.Fatalf("unexpected counters: %#v", resp)
|
|
}
|
|
foundMissing := false
|
|
for _, item := range resp.Items {
|
|
if item.ClientID != "missing-id" {
|
|
continue
|
|
}
|
|
foundMissing = true
|
|
if item.OK {
|
|
t.Fatalf("missing client must fail: %#v", item)
|
|
}
|
|
if item.Code != "TRANSPORT_CLIENT_NOT_FOUND" {
|
|
t.Fatalf("unexpected missing code: %#v", item)
|
|
}
|
|
}
|
|
if !foundMissing {
|
|
t.Fatalf("missing-id result not found: %#v", resp.Items)
|
|
}
|
|
}
|
|
|
|
func TestApplyTransportNetnsToggleLockedNoTargets(t *testing.T) {
|
|
withTransportLifecycleTestPaths(t)
|
|
|
|
now := time.Date(2026, time.March, 9, 20, 10, 0, 0, time.UTC)
|
|
if err := saveTransportClientsState(transportClientsState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportClient{
|
|
{ID: "dnstt-1", Kind: TransportClientDNSTT},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save clients state: %v", err)
|
|
}
|
|
resp := executeTransportNetnsToggleLocked(TransportNetnsToggleRequest{}, now)
|
|
if resp.OK {
|
|
t.Fatalf("expected failure for empty target set")
|
|
}
|
|
if resp.Code != "TRANSPORT_NETNS_NO_TARGETS" {
|
|
t.Fatalf("unexpected code: %q", resp.Code)
|
|
}
|
|
if resp.Count != 0 || len(resp.Items) != 0 {
|
|
t.Fatalf("unexpected non-empty response: %#v", resp)
|
|
}
|
|
}
|
|
|
|
func TestResolveTransportNetnsToggleLockIDsIncludesSameNetnsPeerIface(t *testing.T) {
|
|
withTransportLifecycleTestPaths(t)
|
|
|
|
if err := saveTransportClientsState(transportClientsState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportClient{
|
|
{
|
|
ID: "sg-a",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
IfaceID: "edge-a",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": false,
|
|
"singbox_preflight_check_binary": false,
|
|
},
|
|
},
|
|
{
|
|
ID: "sg-b",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
IfaceID: "edge-b",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
"singbox_preflight_check_binary": false,
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save clients state: %v", err)
|
|
}
|
|
if err := saveTransportInterfacesState(transportInterfacesState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportInterface{
|
|
{ID: transportDefaultIfaceID, Name: "Shared interface", Mode: TransportInterfaceModeShared},
|
|
{ID: "edge-a", Name: "Edge A", Mode: TransportInterfaceModeDedicated, NetnsName: "svpn-shared"},
|
|
{ID: "edge-b", Name: "Edge B", Mode: TransportInterfaceModeDedicated, NetnsName: "svpn-shared"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save interfaces state: %v", err)
|
|
}
|
|
|
|
enable := true
|
|
lockIDs := resolveTransportNetnsToggleLockIDs(TransportNetnsToggleRequest{
|
|
Enabled: &enable,
|
|
ClientIDs: []string{"sg-a"},
|
|
})
|
|
got := strings.Join(normalizeTransportIfaceLockIDs(lockIDs), ",")
|
|
if got != "edge-a,edge-b" {
|
|
t.Fatalf("unexpected lock ids: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExecuteTransportNetnsToggleLockedUsesLifecycleForSameNetnsRestart(t *testing.T) {
|
|
withTransportLifecycleTestPaths(t)
|
|
|
|
configPathA := filepath.Join(t.TempDir(), "sg-a.json")
|
|
if err := saveTransportClientsState(transportClientsState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportClient{
|
|
{
|
|
ID: "sg-a",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
IfaceID: "edge-a",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
"netns_name": "svpn-shared",
|
|
"config_path": configPathA,
|
|
"singbox_preflight_check_binary": false,
|
|
},
|
|
},
|
|
{
|
|
ID: "sg-b",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
IfaceID: "edge-b",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
"netns_name": "svpn-shared",
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save clients state: %v", err)
|
|
}
|
|
saveLinkedSingBoxProfile(t, "sg-a")
|
|
|
|
enable := true
|
|
resp := executeTransportNetnsToggleLocked(TransportNetnsToggleRequest{
|
|
Enabled: &enable,
|
|
ClientIDs: []string{"sg-a"},
|
|
}, time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC))
|
|
|
|
if !resp.OK || resp.SuccessCount != 1 || len(resp.Items) != 1 {
|
|
t.Fatalf("unexpected response: %#v", resp)
|
|
}
|
|
item := resp.Items[0]
|
|
if !item.Provisioned || !item.Restarted {
|
|
t.Fatalf("expected provision+restart path, got %#v", item)
|
|
}
|
|
|
|
current := loadTransportClientsState()
|
|
if got := current.Items[0].Status; got != TransportClientUp {
|
|
t.Fatalf("target must stay up after toggle restart, got=%s", got)
|
|
}
|
|
if got := current.Items[1].Status; got != TransportClientDown {
|
|
t.Fatalf("same-netns peer must be stopped by lifecycle path, got=%s", got)
|
|
}
|
|
}
|
|
|
|
func TestStopTransportSingBoxPeersInSameNetnsLocked(t *testing.T) {
|
|
now := time.Date(2026, time.March, 10, 11, 0, 0, 0, time.UTC)
|
|
st := transportClientsState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportClient{
|
|
{
|
|
ID: "sg-a",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
"netns_name": "svpn-shared",
|
|
},
|
|
},
|
|
{
|
|
ID: "sg-b",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
"netns_name": "svpn-shared",
|
|
},
|
|
},
|
|
{
|
|
ID: "sg-c",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
"netns_name": "svpn-other",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
res := stopTransportSingBoxPeersInSameNetnsLocked(&st, 0, now)
|
|
if !res.OK {
|
|
t.Fatalf("stop peers failed: %#v", res)
|
|
}
|
|
if st.Items[1].Status != TransportClientDown {
|
|
t.Fatalf("peer in same netns must be stopped, got=%s", st.Items[1].Status)
|
|
}
|
|
if st.Items[2].Status != TransportClientUp {
|
|
t.Fatalf("peer in another netns must stay up, got=%s", st.Items[2].Status)
|
|
}
|
|
if !strings.Contains(res.Message, "sg-b") {
|
|
t.Fatalf("result message must include stopped peer id: %#v", res)
|
|
}
|
|
}
|
|
|
|
func TestResolveTransportLifecycleLockIDsUsesIfaceNetnsBinding(t *testing.T) {
|
|
clients := []TransportClient{
|
|
{
|
|
ID: "sg-a",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientDown,
|
|
Enabled: true,
|
|
IfaceID: "edge-a",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
},
|
|
},
|
|
{
|
|
ID: "sg-b",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
IfaceID: "edge-b",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
},
|
|
},
|
|
{
|
|
ID: "sg-c",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
IfaceID: "edge-c",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
},
|
|
},
|
|
}
|
|
ifaces := transportInterfacesState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportInterface{
|
|
{ID: transportDefaultIfaceID, Name: "Shared interface", Mode: TransportInterfaceModeShared},
|
|
{ID: "edge-a", Name: "Edge A", Mode: TransportInterfaceModeDedicated, NetnsName: "svpn-shared"},
|
|
{ID: "edge-b", Name: "Edge B", Mode: TransportInterfaceModeDedicated, NetnsName: "svpn-shared"},
|
|
{ID: "edge-c", Name: "Edge C", Mode: TransportInterfaceModeDedicated, NetnsName: "svpn-other"},
|
|
},
|
|
}
|
|
|
|
lockIDs, ok := resolveTransportLifecycleLockIDsForSnapshot(clients, ifaces, "sg-a", "start")
|
|
if !ok {
|
|
t.Fatalf("expected lock resolution to succeed")
|
|
}
|
|
got := strings.Join(normalizeTransportIfaceLockIDs(lockIDs), ",")
|
|
if got != "edge-a,edge-b" {
|
|
t.Fatalf("unexpected lock ids: %q", got)
|
|
}
|
|
}
|
|
func TestExecuteTransportLifecycleActionLockedStopsSameNetnsPeersAcrossIfaces(t *testing.T) {
|
|
withTransportLifecycleTestPaths(t)
|
|
|
|
if err := saveTransportClientsState(transportClientsState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportClient{
|
|
{
|
|
ID: "sg-a",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientDown,
|
|
Enabled: true,
|
|
IfaceID: "edge-a",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
"netns_name": "svpn-shared",
|
|
},
|
|
},
|
|
{
|
|
ID: "sg-b",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
IfaceID: "edge-b",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
"netns_name": "svpn-shared",
|
|
},
|
|
},
|
|
{
|
|
ID: "sg-c",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
IfaceID: "edge-c",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
"netns_name": "svpn-other",
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save clients state: %v", err)
|
|
}
|
|
|
|
status, resp := executeTransportLifecycleActionLocked("sg-a", "start")
|
|
if status != http.StatusOK {
|
|
t.Fatalf("unexpected status: %d", status)
|
|
}
|
|
if !resp.OK || resp.StatusBefore != TransportClientDown || resp.StatusAfter != TransportClientUp {
|
|
t.Fatalf("unexpected lifecycle response: %#v", resp)
|
|
}
|
|
|
|
current := loadTransportClientsState()
|
|
if got := current.Items[0].Status; got != TransportClientUp {
|
|
t.Fatalf("target must be up after start, got=%s", got)
|
|
}
|
|
if got := current.Items[1].Status; got != TransportClientDown {
|
|
t.Fatalf("same-netns peer must be down after start, got=%s", got)
|
|
}
|
|
if got := current.Items[2].Status; got != TransportClientUp {
|
|
t.Fatalf("other-netns peer must stay up, got=%s", got)
|
|
}
|
|
}
|
|
|
|
func TestExecuteTransportLifecycleActionLockedPersistsPeerStopsOnPeerFailure(t *testing.T) {
|
|
withTransportLifecycleTestPaths(t)
|
|
|
|
if err := saveTransportClientsState(transportClientsState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportClient{
|
|
{
|
|
ID: "sg-a",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientDown,
|
|
Enabled: true,
|
|
IfaceID: "edge-a",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
"netns_name": "svpn-shared",
|
|
},
|
|
},
|
|
{
|
|
ID: "sg-b",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
IfaceID: "edge-b",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": true,
|
|
"netns_name": "svpn-shared",
|
|
},
|
|
},
|
|
{
|
|
ID: "sg-c",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientUp,
|
|
Enabled: true,
|
|
IfaceID: "edge-c",
|
|
Config: map[string]any{
|
|
"runtime_mode": "embedded",
|
|
"netns_enabled": true,
|
|
"netns_name": "svpn-shared",
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save clients state: %v", err)
|
|
}
|
|
|
|
status, resp := executeTransportLifecycleActionLocked("sg-a", "start")
|
|
if status != http.StatusOK {
|
|
t.Fatalf("unexpected status: %d", status)
|
|
}
|
|
if resp.OK || resp.Code != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED" {
|
|
t.Fatalf("unexpected lifecycle failure response: %#v", resp)
|
|
}
|
|
|
|
current := loadTransportClientsState()
|
|
if got := current.Items[0].Status; got != TransportClientDown {
|
|
t.Fatalf("target must stay down after peer stop failure, got=%s", got)
|
|
}
|
|
if got := current.Items[1].Status; got != TransportClientDown {
|
|
t.Fatalf("successful peer stop must persist, got=%s", got)
|
|
}
|
|
if got := current.Items[2].Status; got != TransportClientUp {
|
|
t.Fatalf("failed peer stop must keep previous status, got=%s", got)
|
|
}
|
|
if got := current.Items[2].Runtime.LastAction; got != "stop" {
|
|
t.Fatalf("failed peer stop must update runtime action, got=%q", got)
|
|
}
|
|
}
|
|
|
|
func TestExecuteTransportNetnsToggleLockedPersistsInterfacesAfterClientsCommit(t *testing.T) {
|
|
withTransportLifecycleTestPaths(t)
|
|
|
|
now := time.Date(2026, time.March, 19, 19, 0, 0, 0, time.UTC)
|
|
if err := saveTransportClientsState(transportClientsState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportClient{
|
|
{
|
|
ID: "sb-edge",
|
|
Kind: TransportClientSingBox,
|
|
Status: TransportClientDown,
|
|
Enabled: true,
|
|
IfaceID: "edge-a",
|
|
Config: map[string]any{
|
|
"runner": "mock",
|
|
"netns_enabled": false,
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save clients state: %v", err)
|
|
}
|
|
if err := saveTransportInterfacesState(transportInterfacesState{
|
|
Version: transportStateVersion,
|
|
Items: []TransportInterface{
|
|
{ID: transportDefaultIfaceID, Name: "Shared interface", Mode: TransportInterfaceModeShared},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save interfaces state: %v", err)
|
|
}
|
|
|
|
enable := true
|
|
provision := false
|
|
restart := false
|
|
resp := executeTransportNetnsToggleLocked(TransportNetnsToggleRequest{
|
|
Enabled: &enable,
|
|
ClientIDs: []string{"sb-edge"},
|
|
Provision: &provision,
|
|
RestartRunning: &restart,
|
|
}, now)
|
|
if !resp.OK || resp.SuccessCount != 1 || resp.FailureCount != 0 {
|
|
t.Fatalf("unexpected toggle response: %#v", resp)
|
|
}
|
|
|
|
ifaces := loadTransportInterfacesState()
|
|
if _, ok := findTransportInterfaceByID(ifaces.Items, "edge-a"); !ok {
|
|
t.Fatalf("expected edge-a iface to be persisted in interfaces state: %#v", ifaces.Items)
|
|
}
|
|
}
|