package service import ( "context" "errors" "strings" "testing" "time" "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "oci-portal/internal/crypto" "oci-portal/internal/model" ) // newTestWallet 组装内存库上的 WalletService(admin 已建,面板地址经环境回退注入)。 func newTestWallet(t *testing.T, appURL string) *WalletService { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("open in-memory sqlite: %v", err) } if err := db.AutoMigrate(&model.User{}, &model.UserIdentity{}, &model.UserSession{}, &model.Setting{}); err != nil { t.Fatalf("auto migrate: %v", err) } auth := NewAuthService(db, "test-jwt-secret") if err := auth.EnsureAdmin("admin", "pass123"); err != nil { t.Fatalf("ensure admin: %v", err) } cipher, err := crypto.NewCipher("test-key") if err != nil { t.Fatalf("new cipher: %v", err) } settings := NewSettingService(db, cipher) settings.SetEnvPublicURL(appURL) return NewWalletService(db, settings, auth) } func TestWalletChallenge(t *testing.T) { priv := testPrivKey(9) addr := ethAddrOf(priv) tests := []struct { name string appURL string address string wantErr error }{ {name: "正常下发", appURL: "https://demo.example.com", address: strings.ToLower(addr)}, {name: "无面板地址", appURL: "", address: addr, wantErr: ErrWalletNoAppURL}, {name: "地址非法", appURL: "https://demo.example.com", address: "0x12", wantErr: ErrWalletAddress}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := newTestWallet(t, tt.appURL) nonce, message, err := w.Challenge(context.Background(), tt.address, "login", "", "") if tt.wantErr != nil { if !errors.Is(err, tt.wantErr) { t.Fatalf("Challenge err = %v, want %v", err, tt.wantErr) } return } if err != nil { t.Fatalf("Challenge: %v", err) } // 消息含 EIP-55 规范地址、nonce 与 domain;地址大小写已被规范化 for _, part := range []string{addr, "Nonce: " + nonce, "demo.example.com wants you"} { if !strings.Contains(message, part) { t.Errorf("message missing %q:\n%s", part, message) } } }) } } // adminToken 以口令登录换取 admin 的有效会话令牌(bind 挑战的复验凭据)。 func adminToken(t *testing.T, w *WalletService) string { t.Helper() token, _, err := w.auth.Login(context.Background(), "admin", "pass123", "", SessionMeta{ClientIP: "10.0.0.1"}) if err != nil { t.Fatalf("admin login: %v", err) } return token } // bindWallet 走完整 bind 流程(挑战 → 签名 → 校验),返回校验结果。 func bindWallet(t *testing.T, w *WalletService, seed byte) WalletVerifyResult { t.Helper() priv := testPrivKey(seed) nonce, message, err := w.Challenge(context.Background(), ethAddrOf(priv), "bind", "admin", adminToken(t, w)) if err != nil { t.Fatalf("Challenge(bind): %v", err) } res, err := w.Verify(context.Background(), nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.1.0.1"}) if err != nil { t.Fatalf("Verify(bind): %v", err) } return res } func TestWalletBindAndLogin(t *testing.T) { w := newTestWallet(t, "https://demo.example.com") ctx := context.Background() priv := testPrivKey(9) addr := ethAddrOf(priv) res := bindWallet(t, w, 9) if res.Mode != "bind" || res.Token == "" || res.Username != "admin" { t.Fatalf("bind result = %+v, want bind/admin with token", res) } var row model.UserIdentity if err := w.db.Where("provider = ? AND subject = ?", walletProvider, addr).First(&row).Error; err != nil { t.Fatalf("identity row: %v", err) } if row.Display != shortEthAddress(addr) { t.Errorf("display = %q, want %q", row.Display, shortEthAddress(addr)) } // 钱包身份计入外部身份数(禁用密码登录的门槛) if n, err := identityCountTx(w.db, row.UserID); err != nil || n != 1 { t.Errorf("identityCountTx = (%d, %v), want 1", n, err) } if !w.HasAny(ctx) { t.Error("HasAny = false, want true after bind") } // 重复绑定同一地址被拒 nonce, message, err := w.Challenge(ctx, addr, "bind", "admin", adminToken(t, w)) if err != nil { t.Fatalf("Challenge(rebind): %v", err) } if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.1.0.1"}); !errors.Is(err, ErrWalletBound) { t.Fatalf("rebind err = %v, want ErrWalletBound", err) } // 登录:全大写地址输入被规范化,v=0/1 形态签名同样有效 nonce, message, err = w.Challenge(ctx, "0x"+strings.ToUpper(addr[2:]), "login", "", "") if err != nil { t.Fatalf("Challenge(login): %v", err) } got, err := w.Verify(ctx, nonce, signPersonal(priv, message, false), SessionMeta{ClientIP: "10.1.0.2"}) if err != nil { t.Fatalf("Verify(login): %v", err) } if got.Mode != "login" || got.Username != "admin" || got.Token == "" || got.ExpiresAt.IsZero() { t.Fatalf("login result = %+v, want login/admin with token+expiry", got) } if _, err := w.auth.ParseToken(ctx, got.Token); err != nil { t.Errorf("issued token invalid: %v", err) } } func TestWalletVerifyRejections(t *testing.T) { w := newTestWallet(t, "https://demo.example.com") ctx := context.Background() bound := testPrivKey(9) bindWallet(t, w, 9) t.Run("nonce 一次性", func(t *testing.T) { nonce, message, _ := w.Challenge(ctx, ethAddrOf(bound), "login", "", "") sig := signPersonal(bound, message, true) if _, err := w.Verify(ctx, nonce, sig, SessionMeta{ClientIP: "10.2.0.1"}); err != nil { t.Fatalf("first verify: %v", err) } if _, err := w.Verify(ctx, nonce, sig, SessionMeta{ClientIP: "10.2.0.1"}); !errors.Is(err, ErrWalletChallenge) { t.Errorf("replayed nonce err = %v, want ErrWalletChallenge", err) } }) t.Run("过期挑战", func(t *testing.T) { nonce, message, _ := w.Challenge(ctx, ethAddrOf(bound), "login", "", "") w.mu.Lock() p := w.pending[nonce] p.expires = time.Now().Add(-time.Second) w.pending[nonce] = p w.mu.Unlock() if _, err := w.Verify(ctx, nonce, signPersonal(bound, message, true), SessionMeta{ClientIP: "10.2.0.2"}); !errors.Is(err, ErrWalletChallenge) { t.Errorf("expired nonce err = %v, want ErrWalletChallenge", err) } }) t.Run("他人签名(地址不匹配)", func(t *testing.T) { nonce, message, _ := w.Challenge(ctx, ethAddrOf(bound), "login", "", "") other := testPrivKey(13) if _, err := w.Verify(ctx, nonce, signPersonal(other, message, true), SessionMeta{ClientIP: "10.2.0.3"}); !errors.Is(err, ErrWalletSig) { t.Errorf("foreign signature err = %v, want ErrWalletSig", err) } }) t.Run("未绑定地址拒登", func(t *testing.T) { stranger := testPrivKey(21) nonce, message, _ := w.Challenge(ctx, ethAddrOf(stranger), "login", "", "") if _, err := w.Verify(ctx, nonce, signPersonal(stranger, message, true), SessionMeta{ClientIP: "10.2.0.4"}); !errors.Is(err, ErrWalletNotBound) { t.Errorf("unbound address err = %v, want ErrWalletNotBound", err) } }) } func TestWalletBindStaleToken(t *testing.T) { w := newTestWallet(t, "https://demo.example.com") ctx := context.Background() priv := testPrivKey(9) // 挑战发起后全量撤销(TokenVersion 自增),verify 复验须拒绝绑定 nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", adminToken(t, w)) if err != nil { t.Fatalf("Challenge(bind): %v", err) } if err := w.auth.bumpTokenVersion(ctx, "admin"); err != nil { t.Fatalf("bump token version: %v", err) } if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.4.0.1"}); !errors.Is(err, ErrWalletChallenge) { t.Fatalf("stale-token bind err = %v, want ErrWalletChallenge", err) } if w.HasAny(ctx) { t.Error("HasAny = true, want false: stale-token bind must not persist identity") } } // TestWalletBindStaleVersion 验证事务内版本比对:令牌复验通过但发起后版本 // 已变(极窄竞态窗口)的绑定同样被拒,身份不落库。 func TestWalletBindStaleVersion(t *testing.T) { w := newTestWallet(t, "https://demo.example.com") ctx := context.Background() priv := testPrivKey(9) nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", adminToken(t, w)) if err != nil { t.Fatalf("Challenge(bind): %v", err) } w.mu.Lock() p := w.pending[nonce] p.proof.Ver-- w.pending[nonce] = p w.mu.Unlock() if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.4.0.2"}); !errors.Is(err, ErrWalletChallenge) { t.Fatalf("stale-version bind err = %v, want ErrWalletChallenge", err) } if w.HasAny(ctx) { t.Error("HasAny = true, want false: stale-version bind must not persist identity") } } // TestWalletBindKeepsSession 验证绑定接续当前会话:原行同行更新, // 保留登录方式、行 ID 与 jti,且新令牌有效。 func TestWalletBindKeepsSession(t *testing.T) { w := newTestWallet(t, "https://demo.example.com") ctx := context.Background() priv := testPrivKey(9) token := adminToken(t, w) before := sessionRows(t, w.auth) if len(before) != 1 || before[0].Method != "password" { t.Fatalf("seed session = %+v, want single password row", before) } nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", token) if err != nil { t.Fatalf("Challenge(bind): %v", err) } res, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.1.0.1", UserAgent: "UA"}) if err != nil { t.Fatalf("Verify(bind): %v", err) } after := sessionRows(t, w.auth) if len(after) != 1 || after[0].ID != before[0].ID { t.Fatalf("sessions after bind = %+v, want same single row (continuity)", after) } if after[0].Method != "password" || after[0].TokenID != before[0].TokenID { t.Errorf("renewed row = %+v, want method and jti kept", after[0]) } if _, err := w.auth.ParseToken(ctx, res.Token); err != nil { t.Errorf("issued token invalid: %v", err) } } // TestWalletBindRevokedSession 验证绑定事务的 jti 复核:挑战发起后该设备 // 会话被定点撤销(版本未变),验签通过的绑定仍须拒绝且身份不落库。 func TestWalletBindRevokedSession(t *testing.T) { w := newTestWallet(t, "https://demo.example.com") ctx := context.Background() priv := testPrivKey(9) token := adminToken(t, w) nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", token) if err != nil { t.Fatalf("Challenge(bind): %v", err) } // 模拟入口复验之后、事务之前的撤销:直接标记该 jti 的会话行 revoked // (入口 ParseToken 走缓存路径时可能尚未察觉,事务内查库是最终防线) w.auth.revokeSessionByJTI(ctx, "admin", w.auth.signedJti(token), tokenTTL) if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.5.0.1"}); err == nil { t.Fatal("bind after session revoke succeeded, want rejection") } if w.HasAny(ctx) { t.Error("HasAny = true, want false: revoked-session bind must not persist identity") } } // TestWalletBindNoRowMethodEmpty 验证旧版无会话行令牌绑定时, // 新建会话行的登录方式为空(该令牌并非经钱包登录,与活跃会话设计一致)。 func TestWalletBindNoRowMethodEmpty(t *testing.T) { w := newTestWallet(t, "https://demo.example.com") ctx := context.Background() priv := testPrivKey(9) token := adminToken(t, w) // 删除会话行,模拟升级前签发的存量令牌(无行) if err := w.db.Where("1 = 1").Delete(&model.UserSession{}).Error; err != nil { t.Fatalf("clear sessions: %v", err) } nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", token) if err != nil { t.Fatalf("Challenge(bind): %v", err) } res, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.5.0.2", UserAgent: "UA"}) if err != nil { t.Fatalf("Verify(bind): %v", err) } rows := sessionRows(t, w.auth) if len(rows) != 1 || rows[0].Method != "" { t.Fatalf("rows = %+v, want single row with empty method", rows) } if _, err := w.auth.ParseToken(ctx, res.Token); err != nil { t.Errorf("issued token invalid: %v", err) } } func TestWalletLoginRechecksIdentity(t *testing.T) { w := newTestWallet(t, "https://demo.example.com") bindWallet(t, w, 9) var row model.UserIdentity if err := w.db.Where("provider = ?", walletProvider).First(&row).Error; err != nil { t.Fatalf("find identity: %v", err) } if err := w.db.Delete(&row).Error; err != nil { t.Fatalf("delete identity: %v", err) } _, _, _, err := w.loginWalletIdentity( context.Background(), &row, row.Subject, SessionMeta{ClientIP: "10.6.0.1"}) if !errors.Is(err, ErrWalletNotBound) { t.Fatalf("login err = %v, want ErrWalletNotBound", err) } } func TestWalletHasAnyRequiresAppURL(t *testing.T) { w := newTestWallet(t, "https://demo.example.com") row := model.UserIdentity{UserID: 1, Provider: walletProvider, Subject: "0xabc"} if err := w.db.Create(&row).Error; err != nil { t.Fatalf("seed identity: %v", err) } if !w.HasAny(context.Background()) { t.Fatal("HasAny = false, want true with app url") } w.settings.SetEnvPublicURL("") if w.HasAny(context.Background()) { t.Fatal("HasAny = true without app url") } } func TestWalletLoginGuardLock(t *testing.T) { w := newTestWallet(t, "https://demo.example.com") ctx := context.Background() priv := testPrivKey(9) addr := ethAddrOf(priv) other := testPrivKey(13) var lastErr error for i := 0; i < securityDefaults.LoginFailLimit+1; i++ { nonce, message, err := w.Challenge(ctx, addr, "login", "", "") if err != nil { t.Fatalf("Challenge #%d: %v", i, err) } _, lastErr = w.Verify(ctx, nonce, signPersonal(other, message, true), SessionMeta{ClientIP: "10.3.0.1"}) } if !errors.Is(lastErr, ErrLoginLocked) { t.Fatalf("after %d bad signatures err = %v, want ErrLoginLocked", securityDefaults.LoginFailLimit+1, lastErr) } // 锁定针对「IP+地址」:另一 IP 不连坐 nonce, message, _ := w.Challenge(ctx, addr, "login", "", "") if _, err := w.Verify(ctx, nonce, signPersonal(other, message, true), SessionMeta{ClientIP: "10.3.0.2"}); errors.Is(err, ErrLoginLocked) { t.Errorf("different IP got locked prematurely: %v", err) } }