Files
oci-portal/internal/service/proxy_test.go
T

230 lines
7.2 KiB
Go

package service
import (
"context"
"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)
}
}