351 lines
11 KiB
Go
351 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"oci-portal/internal/crypto"
|
|
"oci-portal/internal/model"
|
|
"oci-portal/internal/oci"
|
|
)
|
|
|
|
func newProxyEnv(t *testing.T) (*ProxyService, *gorm.DB) {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
sqlDB, _ := db.DB()
|
|
sqlDB.SetMaxOpenConns(1)
|
|
if err := db.AutoMigrate(&model.Proxy{}, &model.OciConfig{}); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
cipher, err := crypto.NewCipher("test-key")
|
|
if err != nil {
|
|
t.Fatalf("cipher: %v", err)
|
|
}
|
|
svc := NewProxyService(db, cipher)
|
|
// 默认注入空 client:异步探测直接跳过,避免测试外呼与写库竞态
|
|
svc.geoClientFor = func(*oci.ProxySpec) *http.Client { return nil }
|
|
t.Cleanup(svc.Wait)
|
|
return svc, db
|
|
}
|
|
|
|
func strp(s string) *string { return &s }
|
|
|
|
func TestProxyCRUDAndPasswordSemantics(t *testing.T) {
|
|
svc, db := newProxyEnv(t)
|
|
ctx := context.Background()
|
|
|
|
created, err := svc.Create(ctx, ProxyInput{Name: "jp-socks", Type: "socks5", Host: "1.2.3.4", Port: 1080, Username: "u", Password: strp("secret")})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if !created.PasswordSet {
|
|
t.Fatalf("PasswordSet = false, want true")
|
|
}
|
|
var row model.Proxy
|
|
if err := db.First(&row, created.ID).Error; err != nil {
|
|
t.Fatalf("load row: %v", err)
|
|
}
|
|
if row.PasswordEnc == "" || row.PasswordEnc == "secret" {
|
|
t.Fatalf("password must be stored encrypted, got %q", row.PasswordEnc)
|
|
}
|
|
|
|
// 密码缺省沿用
|
|
updated, err := svc.Update(ctx, created.ID, ProxyInput{Name: "jp-socks", Type: "socks5", Host: "1.2.3.4", Port: 1081})
|
|
if err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if !updated.PasswordSet || updated.Port != 1081 {
|
|
t.Fatalf("update view = %+v, want password kept and port 1081", updated)
|
|
}
|
|
// 空串清除
|
|
cleared, err := svc.Update(ctx, created.ID, ProxyInput{Name: "jp-socks", Type: "socks5", Host: "1.2.3.4", Port: 1081, Password: strp("")})
|
|
if err != nil {
|
|
t.Fatalf("update clear: %v", err)
|
|
}
|
|
if cleared.PasswordSet {
|
|
t.Fatalf("PasswordSet = true after clear, want false")
|
|
}
|
|
|
|
spec, err := svc.SpecOf(ctx, &created.ID)
|
|
if err != nil {
|
|
t.Fatalf("SpecOf: %v", err)
|
|
}
|
|
if spec == nil || spec.Host != "1.2.3.4" || spec.Port != 1081 {
|
|
t.Fatalf("spec = %+v", spec)
|
|
}
|
|
if spec2, err := svc.SpecOf(ctx, nil); err != nil || spec2 != nil {
|
|
t.Fatalf("SpecOf(nil) = %+v, %v; want nil, nil", spec2, err)
|
|
}
|
|
|
|
if err := svc.Delete(ctx, created.ID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProxyValidateAndDeleteInUse(t *testing.T) {
|
|
svc, db := newProxyEnv(t)
|
|
ctx := context.Background()
|
|
|
|
for _, in := range []ProxyInput{
|
|
{Name: "x", Type: "ss", Host: "h", Port: 1080},
|
|
{Name: "x", Type: "http", Host: "h", Port: 0},
|
|
{Name: "x", Type: "http", Host: " ", Port: 8080},
|
|
} {
|
|
if _, err := svc.Create(ctx, in); err == nil {
|
|
t.Fatalf("Create(%+v) accepted invalid input", in)
|
|
}
|
|
}
|
|
|
|
created, err := svc.Create(ctx, ProxyInput{Name: "used", Type: "http", Host: "h", Port: 8080})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
cfg := model.OciConfig{Alias: "t1", ProxyID: &created.ID}
|
|
if err := db.Create(&cfg).Error; err != nil {
|
|
t.Fatalf("create cfg: %v", err)
|
|
}
|
|
if err := svc.Delete(ctx, created.ID); err == nil {
|
|
t.Fatalf("Delete allowed while in use, want ErrProxyInUse")
|
|
}
|
|
views, err := svc.List(ctx)
|
|
if err != nil || len(views) != 1 || views[0].UsedBy != 1 {
|
|
t.Fatalf("List = %+v, %v; want one proxy with UsedBy=1", views, err)
|
|
}
|
|
}
|
|
|
|
func TestProxyAutoNameAndImport(t *testing.T) {
|
|
svc, _ := newProxyEnv(t)
|
|
ctx := context.Background()
|
|
|
|
// 名称缺省自动生成;同 host:port 再建追加序号
|
|
v1, err := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "1.2.3.4", Port: 1080})
|
|
if err != nil || v1.Name != "socks5-1.2.3.4:1080" {
|
|
t.Fatalf("auto name = %q, %v; want socks5-1.2.3.4:1080", v1.Name, err)
|
|
}
|
|
v2, err := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "1.2.3.4", Port: 1080})
|
|
if err != nil || v2.Name != "socks5-1.2.3.4:1080-2" {
|
|
t.Fatalf("auto name #2 = %q, %v; want socks5-1.2.3.4:1080-2", v2.Name, err)
|
|
}
|
|
|
|
text := "# 注释与空行跳过\n\n" +
|
|
"socks5://u1:p1@9.9.9.9:1080\n" +
|
|
"https://8.8.8.8:8443\n" +
|
|
"7.7.7.7:1080:u2:p2\n" +
|
|
"badline\n"
|
|
res, err := svc.Import(ctx, text)
|
|
if err != nil {
|
|
t.Fatalf("import: %v", err)
|
|
}
|
|
if len(res.Created) != 3 || len(res.Failed) != 1 {
|
|
t.Fatalf("import = %d created / %d failed, want 3 / 1: %+v", len(res.Created), len(res.Failed), res)
|
|
}
|
|
if res.Created[0].Type != "socks5" || !res.Created[0].PasswordSet || res.Created[0].Username != "u1" {
|
|
t.Fatalf("URI 行解析 = %+v, want socks5 带凭据", res.Created[0])
|
|
}
|
|
if res.Created[1].Type != "https" || res.Created[1].Port != 8443 {
|
|
t.Fatalf("https 行解析 = %+v", res.Created[1])
|
|
}
|
|
if res.Created[2].Type != "socks5" || res.Created[2].Username != "u2" || !res.Created[2].PasswordSet {
|
|
t.Fatalf("colon 行解析 = %+v, want socks5 带凭据", res.Created[2])
|
|
}
|
|
if res.Failed[0].LineNo != 6 || res.Failed[0].Reason == "" {
|
|
t.Fatalf("failed 行 = %+v, want lineNo=6 带原因", res.Failed[0])
|
|
}
|
|
}
|
|
|
|
func TestProxyImportMasksCredentials(t *testing.T) {
|
|
svc, _ := newProxyEnv(t)
|
|
ctx := context.Background()
|
|
|
|
// 端口非法的行会失败并回显:凭据段必须已脱敏
|
|
res, err := svc.Import(ctx, "socks5://user:topsecret@1.2.3.4:notaport\n1.2.3.4:notaport:user:topsecret")
|
|
if err != nil || len(res.Failed) != 2 {
|
|
t.Fatalf("import = %+v, %v; want 2 failed", res, err)
|
|
}
|
|
for _, f := range res.Failed {
|
|
if strings.Contains(f.Line, "topsecret") {
|
|
t.Fatalf("failed line %q 泄露密码", f.Line)
|
|
}
|
|
if !strings.Contains(f.Line, "***") {
|
|
t.Fatalf("failed line %q 未打码", f.Line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProxyGeoProbe(t *testing.T) {
|
|
svc, db := newProxyEnv(t)
|
|
ctx := context.Background()
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/ok" {
|
|
_, _ = w.Write([]byte(`{"status":"success","country":"日本","city":"大阪市"}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
orig := geoEndpoints
|
|
defer func() { geoEndpoints = orig }()
|
|
// 首个端点失败,验证 fallback 到第二个
|
|
geoEndpoints = []geoEndpoint{
|
|
{url: srv.URL + "/fail", parse: parseIPAPI},
|
|
{url: srv.URL + "/ok", parse: parseIPAPI},
|
|
}
|
|
svc.geoClientFor = func(*oci.ProxySpec) *http.Client { return srv.Client() }
|
|
|
|
created, err := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "1.2.3.4", Port: 1080})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
svc.Wait()
|
|
var row model.Proxy
|
|
if err := db.First(&row, created.ID).Error; err != nil {
|
|
t.Fatalf("load row: %v", err)
|
|
}
|
|
if row.Country != "日本" || row.City != "大阪市" || row.GeoAt == nil {
|
|
t.Fatalf("geo = %q/%q geoAt=%v, want 日本/大阪市 带时间", row.Country, row.City, row.GeoAt)
|
|
}
|
|
|
|
// 全部端点失败:Probe 写空值+时间(未知),不残留旧地区
|
|
geoEndpoints = []geoEndpoint{{url: srv.URL + "/fail", parse: parseIPAPI}}
|
|
view, err := svc.Probe(ctx, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("probe: %v", err)
|
|
}
|
|
if view.Country != "" || view.City != "" || view.GeoAt == "" {
|
|
t.Fatalf("probe view = %+v, want 空地区带探测时间", view)
|
|
}
|
|
}
|
|
|
|
func TestProxySetTenants(t *testing.T) {
|
|
svc, db := newProxyEnv(t)
|
|
ctx := context.Background()
|
|
|
|
pa, _ := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "10.0.0.1", Port: 1080})
|
|
pb, _ := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "10.0.0.2", Port: 1080})
|
|
// t1 已挂 A,t2 挂 B,t3 直连
|
|
t1 := model.OciConfig{Alias: "t1", ProxyID: &pa.ID}
|
|
t2 := model.OciConfig{Alias: "t2", ProxyID: &pb.ID}
|
|
t3 := model.OciConfig{Alias: "t3"}
|
|
for _, cfg := range []*model.OciConfig{&t1, &t2, &t3} {
|
|
if err := db.Create(cfg).Error; err != nil {
|
|
t.Fatalf("seed config: %v", err)
|
|
}
|
|
}
|
|
|
|
proxyOf := func(id uint) *uint {
|
|
var row model.OciConfig
|
|
if err := db.First(&row, id).Error; err != nil {
|
|
t.Fatalf("load config %d: %v", id, err)
|
|
}
|
|
return row.ProxyID
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
proxyID uint
|
|
cfgIDs []uint
|
|
wantN int64
|
|
wantErr error
|
|
}{
|
|
{name: "改挂并新增", proxyID: pa.ID, cfgIDs: []uint{t2.ID, t3.ID, t3.ID}, wantN: 2},
|
|
{name: "清空解除全部", proxyID: pa.ID, cfgIDs: nil, wantN: 0},
|
|
{name: "无效租户拒绝", proxyID: pa.ID, cfgIDs: []uint{9999}, wantErr: ErrProxyInvalid},
|
|
{name: "代理不存在", proxyID: 9999, cfgIDs: nil, wantErr: gorm.ErrRecordNotFound},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
n, err := svc.SetTenants(ctx, tc.proxyID, tc.cfgIDs)
|
|
if tc.wantErr != nil {
|
|
if !errors.Is(err, tc.wantErr) {
|
|
t.Fatalf("err = %v, want %v", err, tc.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil || n != tc.wantN {
|
|
t.Fatalf("SetTenants = %d, %v; want %d, nil", n, err, tc.wantN)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 终态:清空后三租户全部直连(t1 在第一步已被解除,t2/t3 在第二步解除)
|
|
for _, id := range []uint{t1.ID, t2.ID, t3.ID} {
|
|
if got := proxyOf(id); got != nil {
|
|
t.Fatalf("config %d proxyID = %v, want nil", id, *got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProxySetTenantsRebind(t *testing.T) {
|
|
svc, db := newProxyEnv(t)
|
|
ctx := context.Background()
|
|
|
|
pa, _ := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "10.0.1.1", Port: 1080})
|
|
pb, _ := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "10.0.1.2", Port: 1080})
|
|
cfg := model.OciConfig{Alias: "steal", ProxyID: &pa.ID}
|
|
if err := db.Create(&cfg).Error; err != nil {
|
|
t.Fatalf("seed config: %v", err)
|
|
}
|
|
|
|
if _, err := svc.SetTenants(ctx, pb.ID, []uint{cfg.ID}); err != nil {
|
|
t.Fatalf("SetTenants: %v", err)
|
|
}
|
|
var row model.OciConfig
|
|
if err := db.First(&row, cfg.ID).Error; err != nil {
|
|
t.Fatalf("load config: %v", err)
|
|
}
|
|
if row.ProxyID == nil || *row.ProxyID != pb.ID {
|
|
t.Fatalf("proxyID = %v, want %d(改挂到 B)", row.ProxyID, pb.ID)
|
|
}
|
|
// A 侧计数应归零,B 侧为 1
|
|
views, err := svc.List(ctx)
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
for _, v := range views {
|
|
want := int64(0)
|
|
if v.ID == pb.ID {
|
|
want = 1
|
|
}
|
|
if v.UsedBy != want {
|
|
t.Fatalf("proxy %d UsedBy = %d, want %d", v.ID, v.UsedBy, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPersistGeoSkipsDeletedProxy 锁定探测落库防线:代理已删时零命中放弃,
|
|
// 不得经 Save 回退 Create 复活已删代理。
|
|
func TestPersistGeoSkipsDeletedProxy(t *testing.T) {
|
|
svc, db := newProxyEnv(t)
|
|
ctx := context.Background()
|
|
row := model.Proxy{Name: "p", Type: "socks5", Host: "h", Port: 1080}
|
|
if err := db.Create(&row).Error; err != nil {
|
|
t.Fatalf("seed proxy: %v", err)
|
|
}
|
|
svc.persistGeo(ctx, row.ID, geoResult{Country: "DE", City: "FRA"})
|
|
var fresh model.Proxy
|
|
if err := db.First(&fresh, row.ID).Error; err != nil || fresh.Country != "DE" || fresh.GeoAt == nil {
|
|
t.Fatalf("正常落库 = %+v, %v", fresh, err)
|
|
}
|
|
if err := db.Delete(&model.Proxy{}, row.ID).Error; err != nil {
|
|
t.Fatalf("delete proxy: %v", err)
|
|
}
|
|
svc.persistGeo(ctx, row.ID, geoResult{Country: "US"})
|
|
var n int64
|
|
if err := db.Model(&model.Proxy{}).Count(&n).Error; err != nil || n != 0 {
|
|
t.Fatalf("已删代理被复活: count=%d (%v), want 0", n, err)
|
|
}
|
|
}
|