| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506 |
- package securityaudit
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "log/slog"
- "strings"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/config"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/stretchr/testify/require"
- )
- type prefixEncryptor struct{}
- func (prefixEncryptor) Encrypt(value string) (string, error) { return "enc:" + value, nil }
- func (prefixEncryptor) Decrypt(value string) (string, error) {
- if !strings.HasPrefix(value, "enc:") {
- return "", errors.New("cipher: message authentication failed")
- }
- return value[4:], nil
- }
- // testTotpKeyConfig mirrors a deployment with a fixed TOTP_ENCRYPTION_KEY so
- // unit tests may persist endpoint tokens.
- func testTotpKeyConfig() *config.Config {
- return &config.Config{Totp: config.TotpConfig{EncryptionKeyConfigured: true}}
- }
- func TestDefaultConfigIsOff(t *testing.T) {
- storage, err := ParseStorageConfig("")
- require.NoError(t, err)
- require.False(t, storage.Enabled)
- require.False(t, storage.BlockingLatestTurnOnly)
- active, err := ActiveFromStorage(storage, true, prefixEncryptor{})
- require.NoError(t, err)
- require.Equal(t, ModeOff, active.EffectiveMode())
- require.Equal(t, AllScannerIDs, storage.Scanners)
- publicJSON, err := json.Marshal(PublicFromStorage(storage, true, nil))
- require.NoError(t, err)
- require.Contains(t, string(publicJSON), `"group_ids":[]`)
- require.Contains(t, string(publicJSON), `"endpoints":[]`)
- }
- func TestBlockingLatestTurnOnlyConfigRoundTrip(t *testing.T) {
- manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: true}
- request := UpdateConfigRequest{
- ExpectedConfigVersion: 1, Enabled: true, BlockingEnabled: true, BlockingLatestTurnOnly: true,
- Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"pii"}, AllGroups: true,
- Endpoints: []UpdateEndpoint{{
- ID: "guard-1", Name: "Guard", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080",
- Model: DefaultGuardModel, TimeoutMS: 1000, InputLimit: 1000, Enabled: true,
- }},
- }
- next, err := manager.buildNextStorage(DefaultStorageConfig(), request, 9)
- require.NoError(t, err)
- require.True(t, next.BlockingLatestTurnOnly)
- require.Contains(t, changeSummary(next), `"blocking_latest_turn_only":true`)
- active, err := ActiveFromStorage(next, true, prefixEncryptor{})
- require.NoError(t, err)
- require.True(t, active.BlockingLatestTurnOnly)
- public := PublicFromStorage(next, true, nil)
- require.True(t, public.BlockingLatestTurnOnly)
- }
- func TestConfigRejectsBlockingWithoutAudit(t *testing.T) {
- storage := DefaultStorageConfig()
- storage.BlockingEnabled = true
- require.Error(t, validateStorageConfig(storage))
- }
- func TestPublicConfigNeverMarshalsToken(t *testing.T) {
- storage := DefaultStorageConfig()
- storage.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "GUARD_TOKEN_CANARY_SECRET", TimeoutMS: 1000, InputLimit: 1000, Enabled: true}}
- public := PublicFromStorage(storage, true, nil)
- raw, err := json.Marshal(public)
- require.NoError(t, err)
- require.NotContains(t, string(raw), "GUARD_TOKEN_CANARY_SECRET")
- require.NotContains(t, string(raw), "ciphertext")
- require.True(t, public.Endpoints[0].HasToken)
- }
- func TestConfigRuntimeLoadErrorIsStableBoundedAndSecretFree(t *testing.T) {
- const canary = "CONFIG_LOAD_CANARY_SECRET"
- manager := &ConfigManager{clock: fixedClock{}}
- manager.recordLoadError(errors.New("decrypt failed for token " + canary + " Authorization: Bearer " + canary))
- _, _, _, message := manager.RuntimeState()
- require.Equal(t, stableErrorMessage("config_load_failed"), message)
- require.NotContains(t, message, canary)
- require.LessOrEqual(t, len([]rune(message)), 160)
- }
- func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
- t.Run("absent persisted setting is legitimate default", func(t *testing.T) {
- manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
- SettingKeyPromptAuditConfig: "",
- SettingKeyRiskControl: "false",
- }}, nil, prefixEncryptor{}, testTotpKeyConfig())
- require.NoError(t, manager.Reload(context.Background()))
- public, err := manager.Public()
- require.NoError(t, err)
- require.Equal(t, int64(1), public.ConfigVersion)
- require.False(t, public.Enabled)
- })
- t.Run("unparseable persisted config is unavailable", func(t *testing.T) {
- const canary = "persisted-token-canary"
- manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
- // Endpoint without id/name fails validation, so no trustworthy
- // snapshot can be installed from this raw value.
- SettingKeyPromptAuditConfig: `{"enabled":true,"config_version":9,"endpoints":[{"token_ciphertext":"` + canary + `"}]}`,
- SettingKeyRiskControl: "true",
- }}, nil, prefixEncryptor{}, testTotpKeyConfig())
- require.Error(t, manager.Reload(context.Background()))
- public, err := manager.Public()
- require.Error(t, err)
- require.Empty(t, public)
- require.Equal(t, ErrorCodeConfigUnavailable, infraerrors.Reason(err))
- require.NotContains(t, err.Error(), canary)
- })
- t.Run("reload failure preserves last successfully loaded snapshot", func(t *testing.T) {
- storage := DefaultStorageConfig()
- storage.ConfigVersion = 4
- storage.ChangeSummary = "trusted snapshot"
- raw, err := json.Marshal(storage)
- require.NoError(t, err)
- repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{
- SettingKeyPromptAuditConfig: string(raw),
- SettingKeyRiskControl: "false",
- }}}
- manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
- require.NoError(t, manager.Reload(context.Background()))
- repository.loadErr = errors.New("settings unavailable")
- require.Error(t, manager.Reload(context.Background()))
- public, err := manager.Public()
- require.NoError(t, err)
- require.Equal(t, int64(4), public.ConfigVersion)
- require.Equal(t, "trusted snapshot", public.ChangeSummary)
- })
- }
- // Regression coverage for issue #4887: a persisted config whose endpoint token
- // can no longer be decrypted (encryption key changed or auto-generated per
- // boot) must stay visible and editable for admins instead of falling back to a
- // default v1 config that makes every save fail the CAS version check.
- func TestConfigManagerUndecryptableTokenKeepsConfigVisibleAndRecoverable(t *testing.T) {
- const canary = "persisted-token-canary"
- persisted := `{"enabled":true,"blocking_enabled":false,"config_version":9,"endpoints":[{"id":"g1","name":"Guard","protocol":"openai_compatible","base_url":"http://127.0.0.1:8080","model":"m","token_ciphertext":"` + canary + `","timeout_ms":1000,"input_limit":1000,"enabled":true}]}`
- manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
- SettingKeyPromptAuditConfig: persisted,
- SettingKeyRiskControl: "true",
- }}, nil, prefixEncryptor{}, testTotpKeyConfig())
- require.NoError(t, manager.Reload(context.Background()), "an undecryptable token must not fail the whole config load")
- public, err := manager.Public()
- require.NoError(t, err)
- require.Equal(t, int64(9), public.ConfigVersion, "admins must see the real persisted version so CAS saves can succeed")
- require.Len(t, public.Endpoints, 1)
- require.True(t, public.Endpoints[0].HasToken)
- require.Equal(t, "invalid", public.Endpoints[0].TokenStatus)
- raw, err := json.Marshal(public)
- require.NoError(t, err)
- require.NotContains(t, string(raw), canary)
- active, ok := manager.Active()
- require.True(t, ok)
- require.Len(t, active.Endpoints, 1)
- require.False(t, active.Endpoints[0].Enabled, "an endpoint with an undecryptable token must not be used at runtime")
- require.True(t, active.Endpoints[0].TokenInvalid)
- require.Empty(t, active.Endpoints[0].Token)
- require.Empty(t, active.EnabledEndpoints())
- require.Equal(t, []string{"g1"}, active.InvalidTokenEndpointIDs())
- expected, activeVersion, _, _ := manager.RuntimeState()
- require.Equal(t, int64(9), expected)
- require.Equal(t, int64(9), activeVersion)
- }
- func TestConfigManagerUndecryptableTokenStillFailsClosedForBlockingIntent(t *testing.T) {
- persisted := `{"enabled":true,"blocking_enabled":true,"config_version":9,"endpoints":[{"id":"g1","name":"Guard","protocol":"openai_compatible","base_url":"http://127.0.0.1:8080","model":"m","token_ciphertext":"undecryptable","timeout_ms":1000,"input_limit":1000,"enabled":true}]}`
- manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
- SettingKeyPromptAuditConfig: persisted,
- SettingKeyRiskControl: "true",
- }}, nil, prefixEncryptor{}, testTotpKeyConfig())
- require.NoError(t, manager.Reload(context.Background()))
- require.Equal(t, ModeBlocking, manager.EffectiveMode())
- service := &PromptService{config: manager, evaluator: NewGuardEvaluator(NewOpenAICompatibleScanner(), nil, nil)}
- decision, err := service.Evaluate(context.Background(), Request{
- Protocol: "openai_chat_completions",
- Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
- })
- require.Error(t, err, "blocking intent with no usable endpoint must not let requests pass unaudited")
- require.Nil(t, decision)
- var guardErr *GuardError
- require.ErrorAs(t, err, &guardErr)
- require.Equal(t, ErrorCodeUnavailable, guardErr.Code)
- }
- func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) {
- manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: true}
- current := DefaultStorageConfig()
- current.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "enc:old", TimeoutMS: 1000, InputLimit: 1000}}
- base := UpdateConfigRequest{ExpectedConfigVersion: 1, Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"PII"}, AllGroups: true,
- Endpoints: []UpdateEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", TimeoutMS: 1000, InputLimit: 1000}}}
- preserved, err := manager.buildNextStorage(current, base, 9)
- require.NoError(t, err)
- require.Equal(t, "enc:old", preserved.Endpoints[0].TokenCiphertext)
- replacedReq := base
- replacedReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
- replacedReq.Endpoints[0].Token = "new"
- replaced, err := manager.buildNextStorage(current, replacedReq, 9)
- require.NoError(t, err)
- require.Equal(t, "enc:new", replaced.Endpoints[0].TokenCiphertext)
- clearedReq := base
- clearedReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
- clearedReq.Endpoints[0].ClearToken = true
- cleared, err := manager.buildNextStorage(current, clearedReq, 9)
- require.NoError(t, err)
- require.Empty(t, cleared.Endpoints[0].TokenCiphertext)
- }
- // Without a fixed encryption key the per-boot auto-generated key would make a
- // freshly saved token undecryptable after the next restart (issue #4887), so
- // saving a new token must be rejected with an actionable error. Preserving or
- // clearing an existing ciphertext stays allowed so admins can still edit or
- // disable the feature.
- func TestBuildNextStorageRejectsNewTokenWithoutConfiguredEncryptionKey(t *testing.T) {
- manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: false}
- current := DefaultStorageConfig()
- current.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "enc:old", TimeoutMS: 1000, InputLimit: 1000}}
- base := UpdateConfigRequest{ExpectedConfigVersion: 1, Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"PII"}, AllGroups: true,
- Endpoints: []UpdateEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", TimeoutMS: 1000, InputLimit: 1000}}}
- newTokenReq := base
- newTokenReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
- newTokenReq.Endpoints[0].Token = "fresh-token"
- _, err := manager.buildNextStorage(current, newTokenReq, 9)
- require.Error(t, err)
- require.Equal(t, ErrorCodeEncryptionKeyRequired, infraerrors.Reason(err))
- preserved, err := manager.buildNextStorage(current, base, 9)
- require.NoError(t, err)
- require.Equal(t, "enc:old", preserved.Endpoints[0].TokenCiphertext)
- clearedReq := base
- clearedReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
- clearedReq.Endpoints[0].ClearToken = true
- cleared, err := manager.buildNextStorage(current, clearedReq, 9)
- require.NoError(t, err)
- require.Empty(t, cleared.Endpoints[0].TokenCiphertext)
- }
- func TestEffectiveModeTruthTable(t *testing.T) {
- tests := []struct {
- risk, enabled, blocking bool
- want Mode
- }{
- {false, false, false, ModeOff}, {false, true, true, ModeOff}, {true, false, false, ModeOff},
- {true, true, false, ModeAsync}, {true, true, true, ModeBlocking},
- }
- for _, tt := range tests {
- cfg := ActiveConfig{RiskControlEnabled: tt.risk, Enabled: tt.enabled, BlockingEnabled: tt.blocking}
- require.Equal(t, tt.want, cfg.EffectiveMode())
- }
- }
- func TestConfigManagerColdStartOnlyFailsClosedForExplicitBlockingIntent(t *testing.T) {
- manager := &ConfigManager{}
- manager.observeExpectedState(`{"enabled":true,"blocking_enabled":false,"config_version":42}`, true)
- require.Equal(t, int64(42), manager.expected.Load())
- require.Equal(t, ModeOff, manager.EffectiveMode(), "an async config version must not imply blocking")
- require.False(t, manager.BlockingActivationDegraded())
- manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":43}`, false)
- require.Equal(t, ModeOff, manager.EffectiveMode(), "the global risk-control switch still gates blocking")
- manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":44}`, true)
- require.Equal(t, ModeBlocking, manager.EffectiveMode())
- require.True(t, manager.BlockingActivationDegraded())
- manager.observeExpectedState(`{"enabled":true`, true)
- require.Equal(t, ModeBlocking, manager.EffectiveMode(), "undecodable storage must not erase the last known strict intent")
- }
- func TestConfigManagerStaleWeakerSnapshotFailsClosedWhenBlockingExpected(t *testing.T) {
- manager := &ConfigManager{}
- async := ActiveConfig{RiskControlEnabled: true, Enabled: true, BlockingEnabled: false, ConfigVersion: 1}
- manager.snapshot.Store(&activeConfigSnapshot{active: async, storage: DefaultStorageConfig(), loadedAt: fixedClock{}.Now()})
- manager.expected.Store(2)
- manager.expectedBlocking.Store(true)
- require.True(t, manager.BlockingActivationDegraded())
- require.Equal(t, ModeBlocking, manager.EffectiveMode())
- service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)}
- decision, err := service.Evaluate(context.Background(), Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`)})
- require.Error(t, err)
- require.Nil(t, decision)
- var guardErr *GuardError
- require.ErrorAs(t, err, &guardErr)
- require.Equal(t, ErrorCodeUnavailable, guardErr.Code)
- }
- type errorSettingRepository struct{ staticSettingRepository }
- func (errorSettingRepository) GetMultiple(context.Context, []string) (map[string]string, error) {
- return nil, errors.New("settings unavailable")
- }
- type switchableSettingRepository struct {
- staticSettingRepository
- loadErr error
- }
- func (r *switchableSettingRepository) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
- if r.loadErr != nil {
- return nil, r.loadErr
- }
- return r.staticSettingRepository.GetMultiple(ctx, keys)
- }
- func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *testing.T) {
- // Settings unavailable and no prior blocking intent: stay ModeOff so the
- // gateway remains usable and admins can still disable/configure Prompt Audit.
- manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}, testTotpKeyConfig())
- err := manager.Start(context.Background())
- require.Error(t, err)
- require.True(t, manager.configUntrusted.Load())
- require.False(t, manager.BlockingActivationDegraded())
- require.Equal(t, ModeOff, manager.EffectiveMode())
- service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)}
- decision, evalErr := service.Evaluate(context.Background(), Request{
- Protocol: "openai_chat_completions",
- Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
- })
- require.NoError(t, evalErr)
- require.NotNil(t, decision)
- require.Equal(t, DecisionAllow, decision.Kind)
- require.NoError(t, manager.Shutdown(context.Background()))
- }
- func TestConfigManagerStartupLoadFailureFailsClosedWhenBlockingIntended(t *testing.T) {
- manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}, testTotpKeyConfig())
- // Simulate intent observed before a later load failure (e.g. decrypt error).
- manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":3}`, true)
- manager.markConfigUntrusted()
- require.True(t, manager.BlockingActivationDegraded())
- require.Equal(t, ModeBlocking, manager.EffectiveMode())
- service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)}
- decision, err := service.Evaluate(context.Background(), Request{
- Protocol: "openai_chat_completions",
- Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
- })
- require.Error(t, err)
- require.Nil(t, decision)
- var guardErr *GuardError
- require.ErrorAs(t, err, &guardErr)
- require.Equal(t, ErrorCodeUnavailable, guardErr.Code)
- }
- func TestConfigManagerUntrustedClearsOnSuccessfulDisable(t *testing.T) {
- // After a degraded fail-closed period, saving disabled config must restore ModeOff.
- manager := &ConfigManager{encryptor: prefixEncryptor{}, clock: fixedClock{}}
- manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":5}`, true)
- manager.markConfigUntrusted()
- require.Equal(t, ModeBlocking, manager.EffectiveMode())
- // Install a trusted disabled snapshot the same way Save does after commit.
- disabled := DefaultStorageConfig()
- disabled.ConfigVersion = 6
- disabled.Enabled = false
- disabled.BlockingEnabled = false
- active, err := ActiveFromStorage(disabled, true, manager.encryptor)
- require.NoError(t, err)
- manager.expected.Store(disabled.ConfigVersion)
- manager.expectedBlocking.Store(false)
- manager.snapshot.Store(&activeConfigSnapshot{storage: disabled, active: active, loadedAt: manager.clock.Now()})
- manager.configUntrusted.Store(false)
- require.False(t, manager.BlockingActivationDegraded())
- require.Equal(t, ModeOff, manager.EffectiveMode())
- service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)}
- decision, evalErr := service.Evaluate(context.Background(), Request{
- Protocol: "openai_chat_completions",
- Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
- })
- require.NoError(t, evalErr)
- require.Equal(t, DecisionAllow, decision.Kind)
- }
- func TestConfigManagerUntrustedWithoutBlockingDoesNotForceBlockingMode(t *testing.T) {
- manager := &ConfigManager{}
- manager.observeExpectedState(`{"enabled":true,"blocking_enabled":false,"config_version":2}`, true)
- manager.markConfigUntrusted()
- require.False(t, manager.expectedBlocking.Load())
- require.False(t, manager.BlockingActivationDegraded())
- require.Equal(t, ModeOff, manager.EffectiveMode(), "async intent + untrusted must not force blocking unavailable")
- }
- func TestParseLegacyConfigDefaultsMissingFieldsWithoutEnablingBlocking(t *testing.T) {
- storage, err := ParseStorageConfig(`{"enabled":false,"config_version":9}`)
- require.NoError(t, err)
- require.False(t, storage.BlockingEnabled)
- require.Equal(t, "priority", storage.Strategy)
- require.Equal(t, DefaultWorkerCount, storage.WorkerCount)
- require.Equal(t, DefaultQueueCapacity, storage.QueueCapacity)
- require.Equal(t, AllScannerIDs, storage.Scanners)
- require.True(t, storage.AllGroups)
- }
- func TestUpdateConfigStrictBoundsAndKnownValues(t *testing.T) {
- valid := promptAuditUpdateRequest(1, 1, "")
- require.NoError(t, validateUpdateConfigRequest(valid))
- tests := []struct {
- name string
- mutate func(*UpdateConfigRequest)
- reason string
- }{
- {name: "strategy", mutate: func(req *UpdateConfigRequest) { req.Strategy = "round_robin" }, reason: "prompt_audit_invalid_strategy"},
- {name: "worker low", mutate: func(req *UpdateConfigRequest) { req.WorkerCount = 0 }, reason: "prompt_audit_invalid_worker_count"},
- {name: "worker high", mutate: func(req *UpdateConfigRequest) { req.WorkerCount = MaxWorkerCount + 1 }, reason: "prompt_audit_invalid_worker_count"},
- {name: "capacity low", mutate: func(req *UpdateConfigRequest) { req.QueueCapacity = 0 }, reason: "prompt_audit_invalid_queue_capacity"},
- {name: "capacity high", mutate: func(req *UpdateConfigRequest) { req.QueueCapacity = MaxQueueCapacity + 1 }, reason: "prompt_audit_invalid_queue_capacity"},
- {name: "unknown scanner", mutate: func(req *UpdateConfigRequest) { req.Scanners = []string{"made_up"} }, reason: "prompt_audit_invalid_scanner"},
- {name: "group required", mutate: func(req *UpdateConfigRequest) { req.AllGroups = false; req.GroupIDs = nil }, reason: "prompt_audit_groups_required"},
- {name: "group positive", mutate: func(req *UpdateConfigRequest) { req.AllGroups = false; req.GroupIDs = []int64{0} }, reason: "prompt_audit_invalid_group"},
- {name: "timeout low", mutate: func(req *UpdateConfigRequest) { req.Endpoints[0].TimeoutMS = MinTimeoutMS - 1 }, reason: "prompt_audit_invalid_timeout"},
- {name: "timeout high", mutate: func(req *UpdateConfigRequest) { req.Endpoints[0].TimeoutMS = MaxTimeoutMS + 1 }, reason: "prompt_audit_invalid_timeout"},
- {name: "input low", mutate: func(req *UpdateConfigRequest) { req.Endpoints[0].InputLimit = MinInputLimit - 1 }, reason: "prompt_audit_invalid_input_limit"},
- {name: "input high", mutate: func(req *UpdateConfigRequest) { req.Endpoints[0].InputLimit = MaxInputLimit + 1 }, reason: "prompt_audit_invalid_input_limit"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := valid
- req.Scanners = append([]string(nil), valid.Scanners...)
- req.GroupIDs = append([]int64(nil), valid.GroupIDs...)
- req.Endpoints = append([]UpdateEndpoint(nil), valid.Endpoints...)
- tt.mutate(&req)
- err := validateUpdateConfigRequest(req)
- require.Error(t, err)
- require.Equal(t, tt.reason, infraerrors.Reason(err))
- })
- }
- }
- // Regression coverage for issue #5732: refreshLoop reloads every 5s, so
- // config_loaded must stay a change signal instead of ~17k identical lines a
- // day, while still reporting the first load, real config changes and a
- // recovery from a failed reload.
- func TestConfigLoadedIsLoggedOnlyWhenSomethingChanged(t *testing.T) {
- storage := DefaultStorageConfig()
- storage.ConfigVersion = 4
- raw, err := json.Marshal(storage)
- require.NoError(t, err)
- repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{
- SettingKeyPromptAuditConfig: string(raw),
- SettingKeyRiskControl: "false",
- }}}
- manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
- var output bytes.Buffer
- previous := slog.Default()
- slog.SetDefault(slog.New(slog.NewJSONHandler(&output, nil)))
- t.Cleanup(func() { slog.SetDefault(previous) })
- loadedCount := func() int { return strings.Count(output.String(), EventConfigLoaded) }
- require.NoError(t, manager.Reload(context.Background()))
- require.Equal(t, 1, loadedCount(), "the first successful load must be logged")
- require.NoError(t, manager.Reload(context.Background()))
- require.NoError(t, manager.Reload(context.Background()))
- require.Equal(t, 1, loadedCount(), "TTL refreshes of an unchanged config must stay silent")
- repository.values[SettingKeyRiskControl] = "true"
- require.NoError(t, manager.Reload(context.Background()))
- require.Equal(t, 2, loadedCount(), "flipping the global risk control gate must be logged")
- storage.ConfigVersion = 5
- raw, err = json.Marshal(storage)
- require.NoError(t, err)
- repository.values[SettingKeyPromptAuditConfig] = string(raw)
- require.NoError(t, manager.Reload(context.Background()))
- require.Equal(t, 3, loadedCount(), "a new config version must be logged")
- repository.loadErr = errors.New("settings unavailable")
- require.Error(t, manager.Reload(context.Background()))
- require.Equal(t, 3, loadedCount(), "a failed reload must not claim a load")
- repository.loadErr = nil
- require.NoError(t, manager.Reload(context.Background()))
- require.Equal(t, 4, loadedCount(), "recovering from a failed reload must be visible")
- }
|