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