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