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

167 lines
5.5 KiB
Go

package app
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSingBoxProfilesCRUDSecretsAndRevision(t *testing.T) {
tmp := t.TempDir()
oldStatePath := singBoxProfilesStatePath
oldSecretsDir := singBoxSecretsRootDir
singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json")
singBoxSecretsRootDir = filepath.Join(tmp, "transport", "secrets", "singbox")
t.Cleanup(func() {
singBoxProfilesStatePath = oldStatePath
singBoxSecretsRootDir = oldSecretsDir
})
createBody := `{
"name":"Main DE",
"mode":"typed",
"protocol":"vless",
"typed":{"server":"example.org","port":443},
"secrets":{"uuid":"abc-123-token","password":"secret-pass"}
}`
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles", strings.NewReader(createBody))
createRec := httptest.NewRecorder()
handleTransportSingBoxProfiles(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("unexpected 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)
}
item := *createResp.Item
if item.ID == "" {
t.Fatalf("empty profile id")
}
if !item.HasSecrets {
t.Fatalf("profile must report has_secrets=true")
}
if got := item.SecretsMasked["uuid"]; got == "" || strings.Contains(got, "abc-123-token") {
t.Fatalf("secrets must be masked, got=%q", got)
}
stateData, err := os.ReadFile(singBoxProfilesStatePath)
if err != nil {
t.Fatalf("read state: %v", err)
}
if bytes.Contains(stateData, []byte("abc-123-token")) || bytes.Contains(stateData, []byte("secret-pass")) {
t.Fatalf("state file must not contain plain secrets")
}
secretPath := filepath.Join(singBoxSecretsRootDir, item.ID+".json")
secretData, err := os.ReadFile(secretPath)
if err != nil {
t.Fatalf("read secrets file: %v", err)
}
if !bytes.Contains(secretData, []byte("abc-123-token")) {
t.Fatalf("secrets file must contain original value")
}
patchBadReq := httptest.NewRequest(
http.MethodPatch,
"/api/v1/transport/singbox/profiles/"+item.ID,
strings.NewReader(`{"base_revision":9999,"name":"Main FR"}`),
)
patchBadRec := httptest.NewRecorder()
handleTransportSingBoxProfileByID(patchBadRec, patchBadReq)
if patchBadRec.Code != http.StatusConflict {
t.Fatalf("unexpected patch conflict status: %d body=%s", patchBadRec.Code, patchBadRec.Body.String())
}
patchGoodBody := fmt.Sprintf(`{
"base_revision":%d,
"name":"Main FR",
"secrets":{"uuid":"new-token","password":""}
}`, item.ProfileRevision)
patchGoodReq := httptest.NewRequest(
http.MethodPatch,
"/api/v1/transport/singbox/profiles/"+item.ID,
strings.NewReader(patchGoodBody),
)
patchGoodRec := httptest.NewRecorder()
handleTransportSingBoxProfileByID(patchGoodRec, patchGoodReq)
if patchGoodRec.Code != http.StatusOK {
t.Fatalf("unexpected patch status: %d body=%s", patchGoodRec.Code, patchGoodRec.Body.String())
}
var patchResp SingBoxProfilesResponse
if err := json.Unmarshal(patchGoodRec.Body.Bytes(), &patchResp); err != nil {
t.Fatalf("decode patch response: %v", err)
}
if !patchResp.OK || patchResp.Item == nil {
t.Fatalf("patch failed: %#v", patchResp)
}
if patchResp.Item.ProfileRevision != item.ProfileRevision+1 {
t.Fatalf("revision must be incremented: got=%d want=%d", patchResp.Item.ProfileRevision, item.ProfileRevision+1)
}
secretDataAfterPatch, err := os.ReadFile(secretPath)
if err != nil {
t.Fatalf("read secrets after patch: %v", err)
}
if !bytes.Contains(secretDataAfterPatch, []byte("new-token")) {
t.Fatalf("secrets update not persisted")
}
if bytes.Contains(secretDataAfterPatch, []byte("secret-pass")) {
t.Fatalf("secret key with empty value must be removed")
}
deleteBadReq := httptest.NewRequest(
http.MethodDelete,
fmt.Sprintf("/api/v1/transport/singbox/profiles/%s?base_revision=%d", item.ID, item.ProfileRevision),
nil,
)
deleteBadRec := httptest.NewRecorder()
handleTransportSingBoxProfileByID(deleteBadRec, deleteBadReq)
if deleteBadRec.Code != http.StatusConflict {
t.Fatalf("unexpected delete conflict status: %d body=%s", deleteBadRec.Code, deleteBadRec.Body.String())
}
deleteReq := httptest.NewRequest(
http.MethodDelete,
fmt.Sprintf("/api/v1/transport/singbox/profiles/%s?base_revision=%d", item.ID, patchResp.Item.ProfileRevision),
nil,
)
deleteRec := httptest.NewRecorder()
handleTransportSingBoxProfileByID(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("unexpected delete status: %d body=%s", deleteRec.Code, deleteRec.Body.String())
}
if _, err := os.Stat(secretPath); !os.IsNotExist(err) {
t.Fatalf("secrets file must be deleted, err=%v", err)
}
}
func TestSingBoxFeaturesEndpoint(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/transport/singbox/features", nil)
rec := httptest.NewRecorder()
handleTransportSingBoxFeatures(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
var resp SingBoxFeaturesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK {
t.Fatalf("response must be ok: %#v", resp)
}
if !resp.ProfileModes[string(SingBoxProfileModeTyped)] || !resp.ProfileModes[string(SingBoxProfileModeRaw)] {
t.Fatalf("profile modes not advertised: %#v", resp.ProfileModes)
}
}