Files
backend/internal/database/database_test.go
lafay eb931bf1c6
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 46s
feat(auth): implement session-based token management with revocation support
Add Session model, SessionService, and SessionRepository to track user login
sessions and enable token revocation on auth-critical events.

- Introduce explicit TokenType (access/refresh) in JWT claims to prevent
  refresh token misuse via access token endpoints
- Add SessionID field to JWT claims, enabling stateless JWT validation
  against revoked sessions
- Replace legacy Auth middleware with RequireAuth/OptionalAuth pipeline
  that validates token type, account status, and session validity
- Implement session revocation on password change, reset, user ban/inactive,
  and explicit logout
- Add Principal cache with active invalidation for banned/role-changed users
- Fix IDOR vulnerability: GetMessagesByCursor now validates currentUserID
  is conversation participant via GetParticipantStrict
- Add group member visibility checks: announcements, group info, member list
  now require group membership
- Simplify Casbin policy: remove g grouping, use r.sub == p.sub matcher
  with globMatch; user_roles table is single source of truth for roles
- Add migration logic to clean legacy casbin g rules and migrate old p rules
  from path-style to admin/<domain> resource naming
2026-07-05 18:28:08 +08:00

223 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package database
import (
"os"
"path/filepath"
"testing"
"with_you/internal/config"
"with_you/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// 注意:完整的 NewDB→autoMigrate 在 SQLite 下会因 model 包多个表共用
// 同名索引 idx_createddata_change_log / login_log / operation_log而冲突。
// 这是 model 包的既有约束PostgreSQL 经 CREATE INDEX IF NOT EXISTS 容忍),
// 与本次分层拆分无关。因此种子逻辑测试在隔离的 SQLite 库上验证:
// 仅迁移被种子函数读写的表,避开跨表索引冲突,专注验证种子正确性与幂等性。
// newSeedTestDB 创建一个仅迁移种子相关表的 SQLite 测试库。
func newSeedTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "seed_test.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
// 仅迁移种子函数涉及的表(无 idx_created 冲突)
if err := db.AutoMigrate(
&model.Role{},
&model.CasbinRule{},
&model.Channel{},
&model.MaterialSubject{},
&model.Post{}, // dropPostsHotScoreColumnIfExists 依赖 posts 表存在
); err != nil {
t.Fatalf("autoMigrate seed tables failed: %v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}
// TestSeedRoles 验证角色种子写入 5 个预定义角色。
func TestSeedRoles(t *testing.T) {
db := newSeedTestDB(t)
if err := seedRoles(db); err != nil {
t.Fatalf("seedRoles failed: %v", err)
}
var count int64
db.Model(&model.Role{}).Count(&count)
if count != 5 {
t.Errorf("expected 5 seeded roles, got %d", count)
}
expected := []string{
model.RoleSuperAdmin, model.RoleAdmin, model.RoleModerator,
model.RoleUser, model.RoleBanned,
}
for _, name := range expected {
var role model.Role
if err := db.Where("name = ?", name).First(&role).Error; err != nil {
t.Errorf("expected role %q: %v", name, err)
}
}
}
// TestSeedRoles_Idempotent 验证角色种子幂等:表非空时跳过。
func TestSeedRoles_Idempotent(t *testing.T) {
db := newSeedTestDB(t)
if err := seedRoles(db); err != nil {
t.Fatalf("first seedRoles: %v", err)
}
// 第二次调用应跳过(表已有数据)
if err := seedRoles(db); err != nil {
t.Fatalf("second seedRoles: %v", err)
}
var count int64
db.Model(&model.Role{}).Count(&count)
if count != 5 {
t.Errorf("idempotent seed failed: expected 5 roles, got %d", count)
}
}
// TestSeedPermissions 验证权限策略种子写入,含超级管理员通配规则。
func TestSeedPermissions(t *testing.T) {
db := newSeedTestDB(t)
if err := seedPermissions(db); err != nil {
t.Fatalf("seedPermissions failed: %v", err)
}
var count int64
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count)
if count == 0 {
t.Fatal("expected seeded permissions, got 0")
}
// 超级管理员 admin/** 通配globMatch 中 ** 跨 '/'
var rule model.CasbinRule
if err := db.Where("ptype=? AND v0=? AND v1=?", "p", model.RoleSuperAdmin, "admin/**").First(&rule).Error; err != nil {
t.Errorf("expected superadmin admin/** wildcard permission: %v", err)
}
}
// TestSeedPermissions_Idempotent 验证权限种子幂等。
func TestSeedPermissions_Idempotent(t *testing.T) {
db := newSeedTestDB(t)
if err := seedPermissions(db); err != nil {
t.Fatalf("first seedPermissions: %v", err)
}
var count1 int64
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count1)
if err := seedPermissions(db); err != nil {
t.Fatalf("second seedPermissions: %v", err)
}
var count2 int64
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count2)
if count2 != count1 {
t.Errorf("seed not idempotent: first=%d, second=%d", count1, count2)
}
}
// TestSeedChannels 验证频道种子写入 5 个默认频道。
func TestSeedChannels(t *testing.T) {
db := newSeedTestDB(t)
if err := seedChannels(db); err != nil {
t.Fatalf("seedChannels failed: %v", err)
}
var count int64
db.Model(&model.Channel{}).Count(&count)
if count != 5 {
t.Errorf("expected 5 channels, got %d", count)
}
// 抽查一个频道
var ch model.Channel
if err := db.Where("slug = ?", "market").First(&ch).Error; err != nil {
t.Errorf("expected market channel: %v", err)
}
}
// TestSeedMaterialSubjects 验证学科种子写入 8 个默认学科。
func TestSeedMaterialSubjects(t *testing.T) {
db := newSeedTestDB(t)
if err := seedMaterialSubjects(db); err != nil {
t.Fatalf("seedMaterialSubjects failed: %v", err)
}
var count int64
db.Model(&model.MaterialSubject{}).Count(&count)
if count != 8 {
t.Errorf("expected 8 material subjects, got %d", count)
}
}
// TestParseGormLogLevel 验证日志级别解析映射。
func TestParseGormLogLevel(t *testing.T) {
cases := []struct {
in string
want logger.LogLevel
}{
{"silent", logger.Silent},
{"error", logger.Error},
{"warn", logger.Warn},
{"info", logger.Info},
{"", logger.Warn}, // 默认
{"bogus", logger.Warn}, // 未知值降级为 warn
}
for _, c := range cases {
if got := parseGormLogLevel(c.in); got != c.want {
t.Errorf("parseGormLogLevel(%q) = %v, want %v", c.in, got, c.want)
}
}
}
// TestDropPostsHotScoreColumnIfExists_NoTable 验证当 posts 表不存在时安全跳过。
func TestDropPostsHotScoreColumnIfExists_NoTable(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "empty.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
// 未迁移 posts 表 → 应安全返回 nil
if err := dropPostsHotScoreColumnIfExists(db); err != nil {
t.Errorf("expected nil when posts table absent, got: %v", err)
}
}
// 编译期断言:确保 NewDB 签名符合 (*config.DatabaseConfig) -> (*gorm.DB, error)
var _ = func() (*gorm.DB, error) {
return NewDB(&config.DatabaseConfig{})
}
var _ = os.RemoveAll // 保持 os 引用可用(供未来临时文件场景扩展)