170 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|