Skip to content

Commit 61b3b04

Browse files
committed
fix: make batch imports fault tolerant
1 parent 5abf7ba commit 61b3b04

21 files changed

Lines changed: 1592 additions & 212 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
<img src="./logo.svg" alt="SingBox Proxy Manager Logo" width="96" />
66

7-
![Version](https://img.shields.io/badge/version-1.5.1-blue.svg)
7+
![Version](https://img.shields.io/badge/version-1.5.2-blue.svg)
88
![License](https://img.shields.io/badge/license-MIT-green.svg)
99
![SingBox](https://img.shields.io/badge/sing--box-1.12.12-orange.svg)
1010

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package api
2+
3+
import (
4+
"database/sql"
5+
"net/http"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
11+
appdb "sb-proxy/backend/database"
12+
"sb-proxy/backend/models"
13+
"sb-proxy/backend/services"
14+
)
15+
16+
func TestBatchImportRemoteDatabaseIntegration(t *testing.T) {
17+
db := openBatchImportIntegrationDatabase(t)
18+
if db == nil {
19+
t.Skip("remote database integration environment is not set")
20+
}
21+
resetBatchImportIntegrationTables(t, db)
22+
t.Cleanup(func() {
23+
resetBatchImportIntegrationTables(t, db)
24+
_ = db.Close()
25+
})
26+
if err := models.InitDB(db); err != nil {
27+
t.Fatalf("initialize remote database: %v", err)
28+
}
29+
30+
script := `#!/bin/sh
31+
if [ "$1" = "check" ]; then
32+
if grep -q "unsupported-flow" "$3"; then
33+
echo "FATAL[0000] decode config: unsupported flow" >&2
34+
exit 1
35+
fi
36+
exit 0
37+
fi
38+
sleep 300
39+
`
40+
fakeBinary := filepath.Join(t.TempDir(), "fake-sing-box")
41+
if err := os.WriteFile(fakeBinary, []byte(script), 0o755); err != nil {
42+
t.Fatalf("write fake sing-box: %v", err)
43+
}
44+
t.Setenv("SINGBOX_BINARY", fakeBinary)
45+
t.Setenv("SBPM_SKIP_PORT_AVAILABILITY_CHECK", "1")
46+
service := services.NewSingBoxService(t.TempDir())
47+
t.Cleanup(func() { _ = service.Stop() })
48+
handler := NewHandler(db, service)
49+
50+
payload := map[string]interface{}{
51+
"content": strings.Join([]string{
52+
"socks5://user:pass@127.0.0.1:1080#remote-valid-socks",
53+
"vless://00000000-0000-0000-0000-000000000000@127.0.0.1:443?security=tls&flow=unsupported-flow&detour=remote-valid-socks#remote-bad-vless",
54+
}, "\n"),
55+
"enabled": true,
56+
}
57+
recorder := postJSON(t, handler.BatchImportNodes, http.MethodPost, "/api/nodes/batch-import", payload, nil)
58+
if recorder.Code != http.StatusOK {
59+
t.Fatalf("remote batch import failed: status=%d body=%s", recorder.Code, recorder.Body.String())
60+
}
61+
if !strings.Contains(recorder.Body.String(), `"success":1`) || !strings.Contains(recorder.Body.String(), `"failed":1`) {
62+
t.Fatalf("remote batch isolation did not report partial success: %s", recorder.Body.String())
63+
}
64+
65+
var count int
66+
var proxyType, name string
67+
if err := db.QueryRow("SELECT COUNT(*), MIN(type), MIN(name) FROM proxy_nodes").Scan(&count, &proxyType, &name); err != nil {
68+
t.Fatalf("query remote imported nodes: %v", err)
69+
}
70+
if count != 1 || proxyType != "socks5" || name != "remote-valid-socks" {
71+
t.Fatalf("remote database retained wrong nodes: count=%d type=%q name=%q", count, proxyType, name)
72+
}
73+
}
74+
75+
func TestRemoteDatabaseCleanupVerification(t *testing.T) {
76+
db := openBatchImportIntegrationDatabase(t)
77+
if db == nil {
78+
t.Skip("remote database integration environment is not set")
79+
}
80+
defer db.Close()
81+
82+
var query string
83+
switch appdb.DialectFor(db) {
84+
case appdb.DialectPostgres:
85+
query = `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('admin_sessions', 'proxy_nodes', 'settings')`
86+
case appdb.DialectMySQL:
87+
query = `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name IN ('admin_sessions', 'proxy_nodes', 'settings')`
88+
default:
89+
query = `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('admin_sessions', 'proxy_nodes', 'settings')`
90+
}
91+
var count int
92+
if err := db.QueryRow(query).Scan(&count); err != nil {
93+
t.Fatalf("verify remote database cleanup: %v", err)
94+
}
95+
if count != 0 {
96+
t.Fatalf("remote database cleanup left %d application tables", count)
97+
}
98+
}
99+
100+
func openBatchImportIntegrationDatabase(t *testing.T) *sql.DB {
101+
t.Helper()
102+
if databaseURL := strings.TrimSpace(os.Getenv("SBPM_BATCH_IMPORT_INTEGRATION_DATABASE_URL")); databaseURL != "" {
103+
db, dialect, err := appdb.OpenURL(databaseURL)
104+
if err != nil {
105+
t.Fatalf("open remote database: %v", err)
106+
}
107+
appdb.RegisterDialect(db, dialect)
108+
return db
109+
}
110+
111+
tursoURL := strings.TrimSpace(os.Getenv("SBPM_BATCH_IMPORT_INTEGRATION_TURSO_URL"))
112+
tursoToken := strings.TrimSpace(os.Getenv("SBPM_BATCH_IMPORT_INTEGRATION_TURSO_TOKEN"))
113+
if tursoURL == "" || tursoToken == "" {
114+
return nil
115+
}
116+
for _, key := range []string{
117+
"DATABASE_URL", "POSTGRES_DATABASE_URL", "POSTGRES_URL", "PGSQL_DATABASE_URL", "PGSQL",
118+
"MYSQL_DATABASE_URL", "MYSQL",
119+
} {
120+
t.Setenv(key, "")
121+
}
122+
t.Setenv("TURSO_DATABASE_URL", tursoURL)
123+
t.Setenv("TURSO_AUTH_TOKEN", tursoToken)
124+
db, err := appdb.Open(t.TempDir())
125+
if err != nil {
126+
t.Fatalf("open Turso database: %v", err)
127+
}
128+
return db
129+
}
130+
131+
func resetBatchImportIntegrationTables(t *testing.T, db *sql.DB) {
132+
t.Helper()
133+
var inspectionQuery string
134+
switch appdb.DialectFor(db) {
135+
case appdb.DialectPostgres:
136+
inspectionQuery = `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('admin_sessions', 'proxy_nodes', 'settings')`
137+
case appdb.DialectMySQL:
138+
inspectionQuery = `SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name IN ('admin_sessions', 'proxy_nodes', 'settings')`
139+
default:
140+
inspectionQuery = `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('admin_sessions', 'proxy_nodes', 'settings')`
141+
}
142+
rows, err := db.Query(inspectionQuery)
143+
if err != nil {
144+
t.Fatalf("inspect integration tables: %v", err)
145+
}
146+
for rows.Next() {
147+
var tableName string
148+
if err := rows.Scan(&tableName); err != nil {
149+
_ = rows.Close()
150+
t.Fatalf("inspect integration table name: %v", err)
151+
}
152+
}
153+
if err := rows.Err(); err != nil {
154+
_ = rows.Close()
155+
t.Fatalf("inspect integration tables: %v", err)
156+
}
157+
if err := rows.Close(); err != nil {
158+
t.Fatalf("close integration table inspection: %v", err)
159+
}
160+
for _, table := range []string{"admin_sessions", "proxy_nodes", "settings"} {
161+
if _, err := db.Exec("DROP TABLE IF EXISTS " + table); err != nil {
162+
t.Fatalf("reset integration table %s: %v", table, err)
163+
}
164+
}
165+
}

0 commit comments

Comments
 (0)