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