- new internal/sysadmin package (overview/kb/features/settings) with small
port interfaces; business packages stay unaware of sysadmin
- new routes /api/v1/admin/system/{overview,kb,features,settings} guarded
by requireSystemAdmin
- goose 00049: system_settings table (key/jsonb value, updated_by/at)
- SettingSpec whitelist registry with DB > env > default precedence
- register sysadmin in module-boundaries.yaml (archtest rules)
211 lines
6.8 KiB
Go
211 lines
6.8 KiB
Go
package sysadmin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// memStore 进程内 SettingsStore,用于读优先级与写路径测试。
|
|
type memStore struct {
|
|
mu sync.Mutex
|
|
items map[string]memEntry
|
|
}
|
|
|
|
type memEntry struct {
|
|
value json.RawMessage
|
|
updatedBy *int64
|
|
updatedAt time.Time
|
|
}
|
|
|
|
func newMemStore() *memStore {
|
|
return &memStore{items: map[string]memEntry{}}
|
|
}
|
|
|
|
func (m *memStore) Get(_ context.Context, key string) (json.RawMessage, *int64, *time.Time, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
entry, ok := m.items[key]
|
|
if !ok {
|
|
return nil, nil, nil, ErrSettingNotFound
|
|
}
|
|
at := entry.updatedAt
|
|
return entry.value, entry.updatedBy, &at, nil
|
|
}
|
|
|
|
func (m *memStore) Upsert(_ context.Context, key string, value json.RawMessage, updatedBy int64) (time.Time, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
at := time.Now()
|
|
m.items[key] = memEntry{value: append(json.RawMessage(nil), value...), updatedBy: &updatedBy, updatedAt: at}
|
|
return at, 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)
|
|
}
|
|
}
|