package sysadmin import ( "context" "encoding/json" "errors" "sync" "testing" "time" "github.com/jackc/pgx/v5" ) // memStore 进程内 SettingsStore,用于读优先级与写路径测试。 type memStore struct { mu sync.Mutex items map[string]memEntry audits []memAudit failAudit bool } type memEntry struct { value json.RawMessage updatedBy *int64 updatedAt time.Time revision int64 } type memAudit struct { key string before, after json.RawMessage beforeRev, afterRev int64 actor int64 requestID string } func newMemStore() *memStore { return &memStore{items: map[string]memEntry{}} } func (m *memStore) Get(_ context.Context, key string) (json.RawMessage, *int64, *time.Time, int64, error) { m.mu.Lock() defer m.mu.Unlock() entry, ok := m.items[key] if !ok { return nil, nil, nil, 0, ErrSettingNotFound } at := entry.updatedAt return append(json.RawMessage(nil), entry.value...), entry.updatedBy, &at, entry.revision, nil } func (m *memStore) Upsert(_ context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, int64, error) { return m.UpsertWithAudit(context.Background(), key, value, value, updatedBy, "") } func (m *memStore) UpsertWithAudit(_ context.Context, key string, value, auditAfter json.RawMessage, updatedBy int64, requestID string) (time.Time, int64, error) { m.mu.Lock() defer m.mu.Unlock() at := time.Now() prev := m.items[key] before := append(json.RawMessage(nil), prev.value...) after := append(json.RawMessage(nil), value...) m.items[key] = memEntry{ value: after, updatedBy: &updatedBy, updatedAt: at, revision: prev.revision + 1, } m.audits = append(m.audits, memAudit{ key: key, before: before, after: append(json.RawMessage(nil), auditAfter...), beforeRev: prev.revision, afterRev: prev.revision + 1, actor: updatedBy, requestID: requestID, }) return at, prev.revision + 1, nil } func (m *memStore) UpsertCAS(_ context.Context, key string, value json.RawMessage, updatedBy, expectedRevision int64, requestID string) (time.Time, int64, error) { m.mu.Lock() defer m.mu.Unlock() entry, ok := m.items[key] if !ok { return time.Time{}, 0, ErrSettingNotFound } if entry.revision != expectedRevision { return time.Time{}, 0, ErrSettingRevisionConflict } if m.failAudit { return time.Time{}, 0, errors.New("sysadmin: audit write failed") } at := time.Now() newRev := entry.revision + 1 before := append(json.RawMessage(nil), entry.value...) after := append(json.RawMessage(nil), value...) m.items[key] = memEntry{value: after, updatedBy: &updatedBy, updatedAt: at, revision: newRev} m.audits = append(m.audits, memAudit{ key: key, before: before, after: after, beforeRev: entry.revision, afterRev: newRev, actor: updatedBy, requestID: requestID, }) return at, newRev, nil } func (m *memStore) GetForUpdate(ctx context.Context, _ pgx.Tx, key string) (json.RawMessage, *int64, *time.Time, int64, error) { return m.Get(ctx, key) } func (m *memStore) ListPolicyAudits(_ context.Context, key string, limit int) ([]PolicyAudit, error) { m.mu.Lock() defer m.mu.Unlock() if limit <= 0 { limit = 50 } out := make([]PolicyAudit, 0) // audits 按追加顺序;倒序返回最新在前 for i := len(m.audits) - 1; i >= 0; i-- { a := m.audits[i] if a.key != key { continue } beforeRev := a.beforeRev out = append(out, PolicyAudit{ ID: int64(i + 1), SettingKey: a.key, BeforeValue: append(json.RawMessage(nil), a.before...), AfterValue: append(json.RawMessage(nil), a.after...), BeforeRevision: &beforeRev, AfterRevision: a.afterRev, ActorUserID: a.actor, RequestID: a.requestID, CreatedAt: time.Now(), }) if len(out) >= limit { break } } return out, nil } func findSetting(t *testing.T, settings []Setting, key string) Setting { t.Helper() for _, s := range settings { if s.Key == key { return s } } t.Fatalf("setting %s not found", key) return Setting{} } func TestListSettings_FallsBackToDefaultWithoutStore(t *testing.T) { svc := NewService(nil, Options{}) settings, err := svc.ListSettings(context.Background()) if err != nil { t.Fatalf("ListSettings: %v", err) } if len(settings) != len(defaultSettingSpecs()) { t.Fatalf("expected %d settings, got %d", len(defaultSettingSpecs()), len(settings)) } notice := findSetting(t, settings, "ui.maintenance_notice") if notice.Value != "" || notice.Default != "" { t.Fatalf("expected empty default notice, got value=%v default=%v", notice.Value, notice.Default) } if notice.UpdatedAt != nil || notice.UpdatedBy != nil { t.Fatalf("default-resolved setting must not carry update metadata: %+v", notice) } reminder := findSetting(t, settings, "notifications.stage_advance_reminder") if reminder.Value != true { t.Fatalf("expected bool default true, got %v", reminder.Value) } } func TestListSettings_DBOverridesDefault(t *testing.T) { store := newMemStore() if _, _, err := store.Upsert(context.Background(), "ui.maintenance_notice", json.RawMessage(`"停机维护中"`), 7); err != nil { t.Fatalf("seed store: %v", err) } svc := NewService(nil, Options{Store: store}) settings, err := svc.ListSettings(context.Background()) if err != nil { t.Fatalf("ListSettings: %v", err) } notice := findSetting(t, settings, "ui.maintenance_notice") if notice.Value != "停机维护中" { t.Fatalf("expected DB value, got %v", notice.Value) } if notice.UpdatedBy == nil || *notice.UpdatedBy != 7 { t.Fatalf("expected updatedBy=7, got %+v", notice.UpdatedBy) } if notice.UpdatedAt == nil { t.Fatal("expected updatedAt from DB row") } // 未写入的 key 仍回退 default limit := findSetting(t, settings, "kb.default_search_limit") if limit.Value != 8.0 { t.Fatalf("expected default 8, got %v", limit.Value) } } func TestListSettings_EnvLayerBetweenDBAndDefault(t *testing.T) { t.Setenv("SYSADMIN_TEST_FLAG", "true") specs := []SettingSpec{ {Key: "test.env_flag", Label: "env 中间层", Group: "test", Type: SettingTypeBool, Default: false, Writable: true, EnvVar: "SYSADMIN_TEST_FLAG"}, } svc := &Service{specs: specs} settings, err := svc.ListSettings(context.Background()) if err != nil { t.Fatalf("ListSettings: %v", err) } if settings[0].Value != true { t.Fatalf("expected env override true, got %v", settings[0].Value) } // DB 优先于 env store := newMemStore() if _, _, err := store.Upsert(context.Background(), "test.env_flag", json.RawMessage(`false`), 1); err != nil { t.Fatalf("seed store: %v", err) } svc = &Service{specs: specs, store: store} settings, err = svc.ListSettings(context.Background()) if err != nil { t.Fatalf("ListSettings: %v", err) } if settings[0].Value != false { t.Fatalf("expected DB to win over env, got %v", settings[0].Value) } } func TestUpdateSetting_UnknownKey(t *testing.T) { svc := NewService(nil, Options{Store: newMemStore()}) _, err := svc.UpdateSetting(context.Background(), "no.such.key", json.RawMessage(`"x"`), 1) if !errors.Is(err, ErrUnknownSetting) { t.Fatalf("expected ErrUnknownSetting, got %v", err) } } func TestUpdateSetting_ReadOnlyRejected(t *testing.T) { specs := []SettingSpec{ {Key: "test.locked", Label: "只读项", Group: "test", Type: SettingTypeString, Default: "a", Writable: false}, } svc := &Service{specs: specs, store: newMemStore()} _, err := svc.UpdateSetting(context.Background(), "test.locked", json.RawMessage(`"b"`), 1) if !errors.Is(err, ErrSettingReadOnly) { t.Fatalf("expected ErrSettingReadOnly, got %v", err) } } func TestUpdateSetting_TypeValidation(t *testing.T) { svc := NewService(nil, Options{Store: newMemStore()}) cases := []struct { key string raw string }{ {"ui.maintenance_notice", `123`}, // string 声明收到 number {"ui.read_only_mode", `"true"`}, // bool 声明收到 string {"kb.default_search_limit", `"8"`}, // number 声明收到 string {"ui.maintenance_notice", ``}, // 空 body value {"ui.maintenance_notice", `{"a":1}`}, // string 声明收到 object } for _, tc := range cases { _, err := svc.UpdateSetting(context.Background(), tc.key, json.RawMessage(tc.raw), 1) if !errors.Is(err, ErrInvalidSettingValue) { t.Fatalf("%s <- %s: expected ErrInvalidSettingValue, got %v", tc.key, tc.raw, err) } } } func TestUpdateSetting_WithoutStore(t *testing.T) { svc := NewService(nil, Options{}) _, err := svc.UpdateSetting(context.Background(), "ui.maintenance_notice", json.RawMessage(`"x"`), 1) if !errors.Is(err, ErrStoreUnavailable) { t.Fatalf("expected ErrStoreUnavailable, got %v", err) } } func TestUpdateSetting_PersistsAndReturnsUpdatedView(t *testing.T) { svc := NewService(nil, Options{Store: newMemStore()}) setting, err := svc.UpdateSetting(context.Background(), "kb.default_search_limit", json.RawMessage(`12`), 42) if err != nil { t.Fatalf("UpdateSetting: %v", err) } if setting.Value != 12.0 { t.Fatalf("expected 12, got %v", setting.Value) } if setting.Default != 8.0 { t.Fatalf("default must stay 8, got %v", setting.Default) } if setting.UpdatedBy == nil || *setting.UpdatedBy != 42 { t.Fatalf("expected updatedBy=42, got %+v", setting.UpdatedBy) } if setting.UpdatedAt == nil { t.Fatal("expected updatedAt after write") } // 覆盖写:值更新、审计字段跟随 setting, err = svc.UpdateSetting(context.Background(), "kb.default_search_limit", json.RawMessage(`20`), 43) if err != nil { t.Fatalf("second UpdateSetting: %v", err) } if setting.Value != 20.0 || setting.UpdatedBy == nil || *setting.UpdatedBy != 43 { t.Fatalf("expected overwrite (20, by 43), got (%v, %+v)", setting.Value, setting.UpdatedBy) } }