platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
433
selective-vpn-api/app/transport_singbox_profiles_flow_test.go
Normal file
433
selective-vpn-api/app/transport_singbox_profiles_flow_test.go
Normal file
@@ -0,0 +1,433 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user