Persist agent/secrets settings with masked secrets and runner hot-reload; add maintenance windows, maintpage binary, and zero-downtime listener handoff.
301 lines
9.5 KiB
Go
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)
|
|
}
|
|
}
|