Files
weichen 86e9d3706b feat(sysadmin): admin env settings + maintenance FD handoff
Persist agent/secrets settings with masked secrets and runner hot-reload;
add maintenance windows, maintpage binary, and zero-downtime listener handoff.
2026-09-24 23:20:20 +00:00

301 lines
9.5 KiB
Go

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