Files
elmprodvpn/selective-vpn-api/app/transport_handlers_test.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)
}
}