package bell_contact_schedule_test import ( "context" "encoding/json" "errors" "os" "strings" "testing" "time" adminmodels "go-admin/app/admin/models" "go-admin/app/bell/contact" duty "go-admin/app/bell/duty_schedule" "gorm.io/driver/postgres" "gorm.io/gorm" ) func TestContactSchedulePostgres(t *testing.T) { dsn := os.Getenv("BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL") if dsn == "" { t.Skip("set BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL to run PostgreSQL verification") } db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) if err != nil { t.Fatal(err) } ctx := context.Background() key := []byte("0123456789abcdef0123456789abcdef") contacts := contact.NewService(db, key) primary, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人甲", Role: "主值班"}, 1) if err != nil { t.Fatal(err) } backup, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人乙", Role: "备值班"}, 1) if err != nil { t.Fatal(err) } primaryChannel, err := contacts.AddChannel(ctx, primary.ID, contact.ChannelInput{Kind: "sms", Address: "+8613800000001"}, 1) if err != nil { t.Fatal(err) } backupChannel, err := contacts.AddChannel(ctx, backup.ID, contact.ChannelInput{Kind: "voice", Address: "+8613800000002"}, 1) if err != nil { t.Fatal(err) } if strings.Contains(primaryChannel.AddressMasked, "13800000001") { t.Fatal("channel response leaked address") } plain, err := contacts.DecryptChannelAddress(ctx, primaryChannel.ID) if err != nil || plain != "+8613800000001" { t.Fatalf("server-only decrypt failed: %q %v", plain, err) } encoded, _ := json.Marshal(primaryChannel) if strings.Contains(string(encoded), plain) { t.Fatal("serialized channel leaked plaintext") } if _, err = contacts.RecordValidation(ctx, primaryChannel.ID, "verified", "合成验证", 1); err != nil { t.Fatal(err) } if _, err = contacts.RecordValidation(ctx, backupChannel.ID, "verified", "合成验证", 1); err != nil { t.Fatal(err) } if _, err = contacts.Update(ctx, primary.ID, contact.WriteInput{Name: "联系人甲", Role: "主值班", ExpectedVersion: 99}, 1); !errors.Is(err, contact.ErrConflict) { t.Fatalf("stale contact update err=%v", err) } if _, err = contacts.SetEnabled(ctx, primary.ID, false, primary.Version, 1); err != nil { t.Fatal(err) } listed, _, err := contacts.List(ctx, contact.PageQuery{PageIndex: 1, PageSize: 20}) if err != nil || len(listed) != 2 { t.Fatalf("contact list len=%d err=%v", len(listed), err) } var primaryView *contact.ContactView for index := range listed { if listed[index].ID == primary.ID { primaryView = &listed[index] } } if primaryView == nil || primaryView.Enabled || len(primaryView.Channels) != 1 || primaryView.Channels[0].Status != "verified" { t.Fatalf("contact enabled state was coupled to validation: %#v", primaryView) } // Re-enable with the new version before assigning duty. var disabled contact.Contact if err = db.First(&disabled, "id=?", primary.ID).Error; err != nil { t.Fatal(err) } if _, err = contacts.SetEnabled(ctx, primary.ID, true, disabled.Version, 1); err != nil { t.Fatal(err) } duties := duty.NewService(db) group, err := duties.CreateGroup(ctx, duty.GroupInput{Name: "夜间值班组", Timezone: "Asia/Shanghai"}, 1) if err != nil { t.Fatal(err) } if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: primary.ID, Role: "primary"}, 1); err != nil { t.Fatal(err) } if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: backup.ID, Role: "backup"}, 1); err != nil { t.Fatal(err) } if _, err = duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: []duty.SlotInput{{Weekday: 0, StartMinute: 0, EndMinute: 720, PrimaryContactID: primary.ID, BackupContactID: backup.ID}}}, 1); !errors.Is(err, duty.ErrCoverage) { t.Fatalf("schedule gap was accepted: %v", err) } slots := make([]duty.SlotInput, 0, 7) for day := 0; day < 7; day++ { slots = append(slots, duty.SlotInput{Weekday: day, StartMinute: 0, EndMinute: 1440, PrimaryContactID: primary.ID, BackupContactID: backup.ID}) } v1, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: slots}, 1) if err != nil { t.Fatal(err) } published, err := duties.Publish(ctx, v1.ID, 1) if err != nil || published.Status != "published" { t.Fatalf("publish status=%s err=%v", published.Status, err) } v2, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(24 * time.Hour), Slots: slots}, 1) if err != nil || v2.Version != 2 { t.Fatalf("second version=%d err=%v", v2.Version, err) } var persisted duty.ScheduleVersion if err = db.First(&persisted, "id=?", v1.ID).Error; err != nil || persisted.Version != 1 || persisted.Status != "published" { t.Fatalf("historical version changed: %#v err=%v", persisted, err) } now := time.Now().UTC().Add(2 * time.Hour) if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now, EndsAt: now.Add(time.Hour), Reason: "合成替班"}, 1); err != nil { t.Fatal(err) } if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now.Add(30 * time.Minute), EndsAt: now.Add(90 * time.Minute), Reason: "重叠替班"}, 1); !errors.Is(err, duty.ErrConflict) { t.Fatalf("overlap err=%v", err) } if err = db.Model(&contact.ChannelValidation{}).Where("channel_id=?", primaryChannel.ID).Update("detail", "tampered").Error; err == nil { t.Fatal("validation fact was mutable") } if err = db.Model(&duty.RotationSlot{}).Where("schedule_version_id=?", v1.ID).Update("start_minute", 1).Error; err == nil { t.Fatal("published rotation slot was mutable") } if err = db.Model(&duty.ScheduleVersion{}).Where("id=?", v1.ID).Update("effective_from", time.Now().UTC()).Error; err == nil { t.Fatal("published schedule version was mutable") } var menus, reads, writes int64 if err = db.Table("sys_role_menu rm").Joins("JOIN sys_role r ON r.role_id=rm.role_id").Joins("JOIN sys_menu m ON m.menu_id=rm.menu_id").Where("r.role_key=? AND m.path IN ?", "operator", []string{"contacts", "duty-schedules"}).Count(&menus).Error; err != nil { t.Fatal(err) } if err = db.Table("casbin_rule").Where("v0=? AND v2=? AND v1 IN ?", "operator", "GET", []string{"/api/v1/bell/contacts", "/api/v1/bell/duty-groups"}).Count(&reads).Error; err != nil { t.Fatal(err) } if err = db.Table("casbin_rule").Where("v0=? AND v2<>? AND (v1 LIKE ? OR v1 LIKE ?)", "operator", "GET", "/api/v1/bell/contacts%", "/api/v1/bell/duty-%").Count(&writes).Error; err != nil { t.Fatal(err) } if menus != 2 || reads != 2 || writes != 0 { t.Fatalf("operator access escaped scope: menus=%d reads=%d writes=%d", menus, reads, writes) } password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD") if password != "" { var roleID int if err = db.Table("sys_role").Select("role_id").Where("role_key=?", "operator").Scan(&roleID).Error; err != nil || roleID == 0 { t.Fatalf("operator role id=%d err=%v", roleID, err) } user := adminmodels.SysUser{Username: "bell_132_operator", Password: password, NickName: "Bell 处置员", RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"} if err = db.Create(&user).Error; err != nil { t.Fatal(err) } } }