package app import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" transportcfgpkg "selective-vpn-api/app/transportcfg" "strconv" "strings" "testing" "time" ) func TestSingBoxProfileFlowRenderApplyRollbackHistory(t *testing.T) { tmp := t.TempDir() oldStatePath := singBoxProfilesStatePath oldSecretsDir := singBoxSecretsRootDir oldRenderedDir := singBoxRenderedRootDir oldHistoryDir := singBoxHistoryRootDir oldAppliedDir := singBoxAppliedRootDir singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json") singBoxSecretsRootDir = filepath.Join(tmp, "transport", "secrets", "singbox") singBoxRenderedRootDir = filepath.Join(tmp, "transport", "singbox-rendered") singBoxHistoryRootDir = filepath.Join(tmp, "transport", "singbox-history") singBoxAppliedRootDir = filepath.Join(tmp, "transport", "singbox-applied") t.Cleanup(func() { singBoxProfilesStatePath = oldStatePath singBoxSecretsRootDir = oldSecretsDir singBoxRenderedRootDir = oldRenderedDir singBoxHistoryRootDir = oldHistoryDir singBoxAppliedRootDir = oldAppliedDir }) createReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles", strings.NewReader(`{ "id":"e5-flow", "name":"E5 Flow", "mode":"typed", "protocol":"vless", "typed":{"server":"a.example.org","port":443} }`)) createRec := httptest.NewRecorder() handleTransportSingBoxProfiles(createRec, createReq) if createRec.Code != http.StatusOK { t.Fatalf("create status=%d body=%s", createRec.Code, createRec.Body.String()) } var createResp SingBoxProfilesResponse if err := json.Unmarshal(createRec.Body.Bytes(), &createResp); err != nil { t.Fatalf("decode create response: %v", err) } if !createResp.OK || createResp.Item == nil { t.Fatalf("create failed: %#v", createResp) } profileID := createResp.Item.ID baseRev := createResp.Item.ProfileRevision validateReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/validate", strings.NewReader(`{"check_binary":false}`)) validateRec := httptest.NewRecorder() handleTransportSingBoxProfileByID(validateRec, validateReq) if validateRec.Code != http.StatusOK { t.Fatalf("validate status=%d body=%s", validateRec.Code, validateRec.Body.String()) } var validateResp SingBoxProfileValidateResponse if err := json.Unmarshal(validateRec.Body.Bytes(), &validateResp); err != nil { t.Fatalf("decode validate response: %v", err) } if !validateResp.OK || !validateResp.Valid { t.Fatalf("validate must be valid: %#v", validateResp) } renderReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/render", strings.NewReader(`{"check_binary":false}`)) renderRec := httptest.NewRecorder() handleTransportSingBoxProfileByID(renderRec, renderReq) if renderRec.Code != http.StatusOK { t.Fatalf("render status=%d body=%s", renderRec.Code, renderRec.Body.String()) } var renderResp SingBoxProfileRenderResponse if err := json.Unmarshal(renderRec.Body.Bytes(), &renderResp); err != nil { t.Fatalf("decode render response: %v", err) } if !renderResp.OK || !renderResp.Valid { t.Fatalf("render must be valid: %#v", renderResp) } if _, err := os.Stat(renderResp.RenderPath); err != nil { t.Fatalf("rendered file missing: %v", err) } targetCfg := filepath.Join(tmp, "runtime", "singbox.json") applyReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/apply", strings.NewReader(`{ "config_path":"`+targetCfg+`", "skip_runtime":true, "check_binary":false }`)) applyRec := httptest.NewRecorder() handleTransportSingBoxProfileByID(applyRec, applyReq) if applyRec.Code != http.StatusOK { t.Fatalf("apply status=%d body=%s", applyRec.Code, applyRec.Body.String()) } var applyResp SingBoxProfileApplyResponse if err := json.Unmarshal(applyRec.Body.Bytes(), &applyResp); err != nil { t.Fatalf("decode apply response: %v", err) } if !applyResp.OK { t.Fatalf("apply failed: %#v", applyResp) } cfgA, err := os.ReadFile(targetCfg) if err != nil { t.Fatalf("read applied config A: %v", err) } if !strings.Contains(string(cfgA), `"a.example.org"`) { t.Fatalf("applied config must contain first server") } patchReq := httptest.NewRequest(http.MethodPatch, "/api/v1/transport/singbox/profiles/"+profileID, strings.NewReader(`{ "base_revision":`+itoa(baseRev)+`, "typed":{"server":"b.example.org","port":443} }`)) patchRec := httptest.NewRecorder() handleTransportSingBoxProfileByID(patchRec, patchReq) if patchRec.Code != http.StatusOK { t.Fatalf("patch status=%d body=%s", patchRec.Code, patchRec.Body.String()) } var patchResp SingBoxProfilesResponse if err := json.Unmarshal(patchRec.Body.Bytes(), &patchResp); err != nil { t.Fatalf("decode patch response: %v", err) } if !patchResp.OK || patchResp.Item == nil { t.Fatalf("patch failed: %#v", patchResp) } apply2Req := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/apply", strings.NewReader(`{ "config_path":"`+targetCfg+`", "skip_runtime":true, "check_binary":false }`)) apply2Rec := httptest.NewRecorder() handleTransportSingBoxProfileByID(apply2Rec, apply2Req) if apply2Rec.Code != http.StatusOK { t.Fatalf("apply2 status=%d body=%s", apply2Rec.Code, apply2Rec.Body.String()) } var apply2Resp SingBoxProfileApplyResponse if err := json.Unmarshal(apply2Rec.Body.Bytes(), &apply2Resp); err != nil { t.Fatalf("decode apply2 response: %v", err) } if !apply2Resp.OK { t.Fatalf("apply2 failed: %#v", apply2Resp) } cfgB, err := os.ReadFile(targetCfg) if err != nil { t.Fatalf("read applied config B: %v", err) } if !strings.Contains(string(cfgB), `"b.example.org"`) { t.Fatalf("applied config must contain second server") } rollbackReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/rollback", strings.NewReader(`{ "config_path":"`+targetCfg+`", "skip_runtime":true }`)) rollbackRec := httptest.NewRecorder() handleTransportSingBoxProfileByID(rollbackRec, rollbackReq) if rollbackRec.Code != http.StatusOK { t.Fatalf("rollback status=%d body=%s", rollbackRec.Code, rollbackRec.Body.String()) } var rollbackResp SingBoxProfileRollbackResponse if err := json.Unmarshal(rollbackRec.Body.Bytes(), &rollbackResp); err != nil { t.Fatalf("decode rollback response: %v", err) } if !rollbackResp.OK { t.Fatalf("rollback failed: %#v", rollbackResp) } cfgAfterRollback, err := os.ReadFile(targetCfg) if err != nil { t.Fatalf("read config after rollback: %v", err) } if string(cfgAfterRollback) != string(cfgA) { t.Fatalf("rollback must restore first config") } historyReq := httptest.NewRequest(http.MethodGet, "/api/v1/transport/singbox/profiles/"+profileID+"/history?limit=20", nil) historyRec := httptest.NewRecorder() handleTransportSingBoxProfileByID(historyRec, historyReq) if historyRec.Code != http.StatusOK { t.Fatalf("history status=%d body=%s", historyRec.Code, historyRec.Body.String()) } var historyResp SingBoxProfileHistoryResponse if err := json.Unmarshal(historyRec.Body.Bytes(), &historyResp); err != nil { t.Fatalf("decode history response: %v", err) } if !historyResp.OK || historyResp.Count == 0 { t.Fatalf("history must return items: %#v", historyResp) } hasRollback := false for _, it := range historyResp.Items { if it.Action == "rollback" && it.Status == "success" { hasRollback = true break } } if !hasRollback { t.Fatalf("history must contain successful rollback action") } } func TestSingBoxProfileValidateFail(t *testing.T) { tmp := t.TempDir() oldStatePath := singBoxProfilesStatePath oldSecretsDir := singBoxSecretsRootDir oldRenderedDir := singBoxRenderedRootDir oldHistoryDir := singBoxHistoryRootDir oldAppliedDir := singBoxAppliedRootDir singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json") singBoxSecretsRootDir = filepath.Join(tmp, "transport", "secrets", "singbox") singBoxRenderedRootDir = filepath.Join(tmp, "transport", "singbox-rendered") singBoxHistoryRootDir = filepath.Join(tmp, "transport", "singbox-history") singBoxAppliedRootDir = filepath.Join(tmp, "transport", "singbox-applied") t.Cleanup(func() { singBoxProfilesStatePath = oldStatePath singBoxSecretsRootDir = oldSecretsDir singBoxRenderedRootDir = oldRenderedDir singBoxHistoryRootDir = oldHistoryDir singBoxAppliedRootDir = oldAppliedDir }) createReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles", strings.NewReader(`{ "id":"invalid-typed", "mode":"typed", "typed":{"port":443} }`)) createRec := httptest.NewRecorder() handleTransportSingBoxProfiles(createRec, createReq) if createRec.Code != http.StatusOK { t.Fatalf("create status=%d body=%s", createRec.Code, createRec.Body.String()) } validateReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/invalid-typed/validate", strings.NewReader(`{"check_binary":false}`)) validateRec := httptest.NewRecorder() handleTransportSingBoxProfileByID(validateRec, validateReq) if validateRec.Code != http.StatusOK { t.Fatalf("validate status=%d body=%s", validateRec.Code, validateRec.Body.String()) } var resp SingBoxProfileValidateResponse if err := json.Unmarshal(validateRec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.OK || resp.Valid { t.Fatalf("validate must fail for incomplete typed profile: %#v", resp) } if len(resp.Errors) == 0 { t.Fatalf("validation errors expected") } } func TestNormalizeSingBoxRenderedConfigDropsLegacyPacketEncodingNone(t *testing.T) { cfg := map[string]any{ "outbounds": []any{ map[string]any{ "type": "vless", "packet_encoding": "none", }, map[string]any{ "type": "vless", "packet_encoding": "xudp", }, map[string]any{ "type": "trojan", "packet_encoding": "none", }, }, } out := transportcfgpkg.NormalizeSingBoxRenderedConfig(cfg, asString) rows, _ := out["outbounds"].([]any) if len(rows) != 3 { t.Fatalf("unexpected outbounds len: %d", len(rows)) } vlessLegacy, _ := rows[0].(map[string]any) if _, ok := vlessLegacy["packet_encoding"]; ok { t.Fatalf("legacy vless packet_encoding=none must be removed: %#v", vlessLegacy) } vlessXUDP, _ := rows[1].(map[string]any) if got := strings.TrimSpace(asString(vlessXUDP["packet_encoding"])); got != "xudp" { t.Fatalf("xudp must be preserved, got=%q", got) } trojan, _ := rows[2].(map[string]any) if got := strings.TrimSpace(asString(trojan["packet_encoding"])); got != "none" { t.Fatalf("non-vless packet_encoding must be unchanged, got=%q", got) } } func TestNormalizeSingBoxRenderedConfigDropsVLESSFlowNone(t *testing.T) { cfg := map[string]any{ "outbounds": []any{ map[string]any{ "type": "vless", "flow": "none", }, map[string]any{ "type": "vless", "flow": "xtls-rprx-vision", }, map[string]any{ "type": "trojan", "flow": "none", }, }, } out := transportcfgpkg.NormalizeSingBoxRenderedConfig(cfg, asString) rows, _ := out["outbounds"].([]any) if len(rows) != 3 { t.Fatalf("unexpected outbounds len: %d", len(rows)) } vlessNone, _ := rows[0].(map[string]any) if _, ok := vlessNone["flow"]; ok { t.Fatalf("legacy vless flow=none must be removed: %#v", vlessNone) } vlessVision, _ := rows[1].(map[string]any) if got := strings.TrimSpace(asString(vlessVision["flow"])); got != "xtls-rprx-vision" { t.Fatalf("vless valid flow must be preserved, got=%q", got) } trojan, _ := rows[2].(map[string]any) if got := strings.TrimSpace(asString(trojan["flow"])); got != "none" { t.Fatalf("non-vless flow must be unchanged, got=%q", got) } } func TestPrepareSingBoxClientProfileWritesConfigForLinkedClient(t *testing.T) { tmp := t.TempDir() oldStatePath := singBoxProfilesStatePath oldRenderedDir := singBoxRenderedRootDir oldAppliedDir := singBoxAppliedRootDir 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() { singBoxProfilesStatePath = oldStatePath singBoxRenderedRootDir = oldRenderedDir singBoxAppliedRootDir = oldAppliedDir }) profile := SingBoxProfile{ ID: "c-sg", Name: "Linked SG", 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": "c-sg", }, ProfileRevision: 1, } state := singBoxProfilesState{ Version: singBoxProfilesStateVersion, Items: []SingBoxProfile{profile}, UpdatedAt: time.Now().UTC().Format(time.RFC3339), Revision: 1, } if err := saveSingBoxProfilesState(state); err != nil { t.Fatalf("save profile state: %v", err) } configPath := filepath.Join(tmp, "runtime", "c-sg.json") client := TransportClient{ ID: "c-sg", Kind: TransportClientSingBox, Config: map[string]any{ "config_path": configPath, }, } res := prepareSingBoxClientProfile(client, false) if !res.OK { t.Fatalf("prepare failed: %#v", res) } data, err := os.ReadFile(configPath) if err != nil { t.Fatalf("read prepared config: %v", err) } text := string(data) if strings.Contains(text, "\"packet_encoding\": \"none\"") { t.Fatalf("prepared config must not contain legacy packet_encoding=none: %s", text) } if !strings.Contains(text, "\"server\": \"example.com\"") { t.Fatalf("prepared config must contain outbound server: %s", text) } } func TestFindSingBoxProfileIndexForClient(t *testing.T) { st := singBoxProfilesState{ Items: []SingBoxProfile{ {ID: "p-1", ProfileRevision: 1, Meta: map[string]any{"client_id": "c-1"}}, {ID: "p-2", ProfileRevision: 3, Meta: map[string]any{"client_id": "c-1"}}, {ID: "c-2", ProfileRevision: 1}, }, } if idx := findSingBoxProfileIndexForClient(st, "c-1"); idx != 1 { t.Fatalf("expected highest revision linked profile idx=1, got=%d", idx) } if idx := findSingBoxProfileIndexForClient(st, "c-2"); idx != 2 { t.Fatalf("expected id fallback idx=2, got=%d", idx) } if idx := findSingBoxProfileIndexForClient(st, "missing"); idx != -1 { t.Fatalf("expected missing=-1, got=%d", idx) } } func itoa(n int64) string { return strconv.FormatInt(n, 10) }