Files
yovision/Bell/server/tests/bell_contact_schedule/integration_test.go
T

170 lines
7.3 KiB
Go

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)
}
}
}