Files
elmprodvpn/selective-vpn-api/app/transport_singbox_profiles_flow_test.go

434 lines
15 KiB
Go

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