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
223 lines
6.1 KiB
Go
223 lines
6.1 KiB
Go
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_created(data_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 引用可用(供未来临时文件场景扩展)
|