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) } }