prompt_config_test.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. package securityaudit
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "log/slog"
  8. "strings"
  9. "testing"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  12. "github.com/stretchr/testify/require"
  13. )
  14. type prefixEncryptor struct{}
  15. func (prefixEncryptor) Encrypt(value string) (string, error) { return "enc:" + value, nil }
  16. func (prefixEncryptor) Decrypt(value string) (string, error) {
  17. if !strings.HasPrefix(value, "enc:") {
  18. return "", errors.New("cipher: message authentication failed")
  19. }
  20. return value[4:], nil
  21. }
  22. // testTotpKeyConfig mirrors a deployment with a fixed TOTP_ENCRYPTION_KEY so
  23. // unit tests may persist endpoint tokens.
  24. func testTotpKeyConfig() *config.Config {
  25. return &config.Config{Totp: config.TotpConfig{EncryptionKeyConfigured: true}}
  26. }
  27. func TestDefaultConfigIsOff(t *testing.T) {
  28. storage, err := ParseStorageConfig("")
  29. require.NoError(t, err)
  30. require.False(t, storage.Enabled)
  31. require.False(t, storage.BlockingLatestTurnOnly)
  32. active, err := ActiveFromStorage(storage, true, prefixEncryptor{})
  33. require.NoError(t, err)
  34. require.Equal(t, ModeOff, active.EffectiveMode())
  35. require.Equal(t, AllScannerIDs, storage.Scanners)
  36. publicJSON, err := json.Marshal(PublicFromStorage(storage, true, nil))
  37. require.NoError(t, err)
  38. require.Contains(t, string(publicJSON), `"group_ids":[]`)
  39. require.Contains(t, string(publicJSON), `"endpoints":[]`)
  40. }
  41. func TestBlockingLatestTurnOnlyConfigRoundTrip(t *testing.T) {
  42. manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: true}
  43. request := UpdateConfigRequest{
  44. ExpectedConfigVersion: 1, Enabled: true, BlockingEnabled: true, BlockingLatestTurnOnly: true,
  45. Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"pii"}, AllGroups: true,
  46. Endpoints: []UpdateEndpoint{{
  47. ID: "guard-1", Name: "Guard", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080",
  48. Model: DefaultGuardModel, TimeoutMS: 1000, InputLimit: 1000, Enabled: true,
  49. }},
  50. }
  51. next, err := manager.buildNextStorage(DefaultStorageConfig(), request, 9)
  52. require.NoError(t, err)
  53. require.True(t, next.BlockingLatestTurnOnly)
  54. require.Contains(t, changeSummary(next), `"blocking_latest_turn_only":true`)
  55. active, err := ActiveFromStorage(next, true, prefixEncryptor{})
  56. require.NoError(t, err)
  57. require.True(t, active.BlockingLatestTurnOnly)
  58. public := PublicFromStorage(next, true, nil)
  59. require.True(t, public.BlockingLatestTurnOnly)
  60. }
  61. func TestConfigRejectsBlockingWithoutAudit(t *testing.T) {
  62. storage := DefaultStorageConfig()
  63. storage.BlockingEnabled = true
  64. require.Error(t, validateStorageConfig(storage))
  65. }
  66. func TestPublicConfigNeverMarshalsToken(t *testing.T) {
  67. storage := DefaultStorageConfig()
  68. 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}}
  69. public := PublicFromStorage(storage, true, nil)
  70. raw, err := json.Marshal(public)
  71. require.NoError(t, err)
  72. require.NotContains(t, string(raw), "GUARD_TOKEN_CANARY_SECRET")
  73. require.NotContains(t, string(raw), "ciphertext")
  74. require.True(t, public.Endpoints[0].HasToken)
  75. }
  76. func TestConfigRuntimeLoadErrorIsStableBoundedAndSecretFree(t *testing.T) {
  77. const canary = "CONFIG_LOAD_CANARY_SECRET"
  78. manager := &ConfigManager{clock: fixedClock{}}
  79. manager.recordLoadError(errors.New("decrypt failed for token " + canary + " Authorization: Bearer " + canary))
  80. _, _, _, message := manager.RuntimeState()
  81. require.Equal(t, stableErrorMessage("config_load_failed"), message)
  82. require.NotContains(t, message, canary)
  83. require.LessOrEqual(t, len([]rune(message)), 160)
  84. }
  85. func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
  86. t.Run("absent persisted setting is legitimate default", func(t *testing.T) {
  87. manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
  88. SettingKeyPromptAuditConfig: "",
  89. SettingKeyRiskControl: "false",
  90. }}, nil, prefixEncryptor{}, testTotpKeyConfig())
  91. require.NoError(t, manager.Reload(context.Background()))
  92. public, err := manager.Public()
  93. require.NoError(t, err)
  94. require.Equal(t, int64(1), public.ConfigVersion)
  95. require.False(t, public.Enabled)
  96. })
  97. t.Run("unparseable persisted config is unavailable", func(t *testing.T) {
  98. const canary = "persisted-token-canary"
  99. manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
  100. // Endpoint without id/name fails validation, so no trustworthy
  101. // snapshot can be installed from this raw value.
  102. SettingKeyPromptAuditConfig: `{"enabled":true,"config_version":9,"endpoints":[{"token_ciphertext":"` + canary + `"}]}`,
  103. SettingKeyRiskControl: "true",
  104. }}, nil, prefixEncryptor{}, testTotpKeyConfig())
  105. require.Error(t, manager.Reload(context.Background()))
  106. public, err := manager.Public()
  107. require.Error(t, err)
  108. require.Empty(t, public)
  109. require.Equal(t, ErrorCodeConfigUnavailable, infraerrors.Reason(err))
  110. require.NotContains(t, err.Error(), canary)
  111. })
  112. t.Run("reload failure preserves last successfully loaded snapshot", func(t *testing.T) {
  113. storage := DefaultStorageConfig()
  114. storage.ConfigVersion = 4
  115. storage.ChangeSummary = "trusted snapshot"
  116. raw, err := json.Marshal(storage)
  117. require.NoError(t, err)
  118. repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{
  119. SettingKeyPromptAuditConfig: string(raw),
  120. SettingKeyRiskControl: "false",
  121. }}}
  122. manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
  123. require.NoError(t, manager.Reload(context.Background()))
  124. repository.loadErr = errors.New("settings unavailable")
  125. require.Error(t, manager.Reload(context.Background()))
  126. public, err := manager.Public()
  127. require.NoError(t, err)
  128. require.Equal(t, int64(4), public.ConfigVersion)
  129. require.Equal(t, "trusted snapshot", public.ChangeSummary)
  130. })
  131. }
  132. // Regression coverage for issue #4887: a persisted config whose endpoint token
  133. // can no longer be decrypted (encryption key changed or auto-generated per
  134. // boot) must stay visible and editable for admins instead of falling back to a
  135. // default v1 config that makes every save fail the CAS version check.
  136. func TestConfigManagerUndecryptableTokenKeepsConfigVisibleAndRecoverable(t *testing.T) {
  137. const canary = "persisted-token-canary"
  138. 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}]}`
  139. manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
  140. SettingKeyPromptAuditConfig: persisted,
  141. SettingKeyRiskControl: "true",
  142. }}, nil, prefixEncryptor{}, testTotpKeyConfig())
  143. require.NoError(t, manager.Reload(context.Background()), "an undecryptable token must not fail the whole config load")
  144. public, err := manager.Public()
  145. require.NoError(t, err)
  146. require.Equal(t, int64(9), public.ConfigVersion, "admins must see the real persisted version so CAS saves can succeed")
  147. require.Len(t, public.Endpoints, 1)
  148. require.True(t, public.Endpoints[0].HasToken)
  149. require.Equal(t, "invalid", public.Endpoints[0].TokenStatus)
  150. raw, err := json.Marshal(public)
  151. require.NoError(t, err)
  152. require.NotContains(t, string(raw), canary)
  153. active, ok := manager.Active()
  154. require.True(t, ok)
  155. require.Len(t, active.Endpoints, 1)
  156. require.False(t, active.Endpoints[0].Enabled, "an endpoint with an undecryptable token must not be used at runtime")
  157. require.True(t, active.Endpoints[0].TokenInvalid)
  158. require.Empty(t, active.Endpoints[0].Token)
  159. require.Empty(t, active.EnabledEndpoints())
  160. require.Equal(t, []string{"g1"}, active.InvalidTokenEndpointIDs())
  161. expected, activeVersion, _, _ := manager.RuntimeState()
  162. require.Equal(t, int64(9), expected)
  163. require.Equal(t, int64(9), activeVersion)
  164. }
  165. func TestConfigManagerUndecryptableTokenStillFailsClosedForBlockingIntent(t *testing.T) {
  166. 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}]}`
  167. manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
  168. SettingKeyPromptAuditConfig: persisted,
  169. SettingKeyRiskControl: "true",
  170. }}, nil, prefixEncryptor{}, testTotpKeyConfig())
  171. require.NoError(t, manager.Reload(context.Background()))
  172. require.Equal(t, ModeBlocking, manager.EffectiveMode())
  173. service := &PromptService{config: manager, evaluator: NewGuardEvaluator(NewOpenAICompatibleScanner(), nil, nil)}
  174. decision, err := service.Evaluate(context.Background(), Request{
  175. Protocol: "openai_chat_completions",
  176. Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
  177. })
  178. require.Error(t, err, "blocking intent with no usable endpoint must not let requests pass unaudited")
  179. require.Nil(t, decision)
  180. var guardErr *GuardError
  181. require.ErrorAs(t, err, &guardErr)
  182. require.Equal(t, ErrorCodeUnavailable, guardErr.Code)
  183. }
  184. func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) {
  185. manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: true}
  186. current := DefaultStorageConfig()
  187. 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}}
  188. base := UpdateConfigRequest{ExpectedConfigVersion: 1, Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"PII"}, AllGroups: true,
  189. Endpoints: []UpdateEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", TimeoutMS: 1000, InputLimit: 1000}}}
  190. preserved, err := manager.buildNextStorage(current, base, 9)
  191. require.NoError(t, err)
  192. require.Equal(t, "enc:old", preserved.Endpoints[0].TokenCiphertext)
  193. replacedReq := base
  194. replacedReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
  195. replacedReq.Endpoints[0].Token = "new"
  196. replaced, err := manager.buildNextStorage(current, replacedReq, 9)
  197. require.NoError(t, err)
  198. require.Equal(t, "enc:new", replaced.Endpoints[0].TokenCiphertext)
  199. clearedReq := base
  200. clearedReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
  201. clearedReq.Endpoints[0].ClearToken = true
  202. cleared, err := manager.buildNextStorage(current, clearedReq, 9)
  203. require.NoError(t, err)
  204. require.Empty(t, cleared.Endpoints[0].TokenCiphertext)
  205. }
  206. // Without a fixed encryption key the per-boot auto-generated key would make a
  207. // freshly saved token undecryptable after the next restart (issue #4887), so
  208. // saving a new token must be rejected with an actionable error. Preserving or
  209. // clearing an existing ciphertext stays allowed so admins can still edit or
  210. // disable the feature.
  211. func TestBuildNextStorageRejectsNewTokenWithoutConfiguredEncryptionKey(t *testing.T) {
  212. manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: false}
  213. current := DefaultStorageConfig()
  214. 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}}
  215. base := UpdateConfigRequest{ExpectedConfigVersion: 1, Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"PII"}, AllGroups: true,
  216. Endpoints: []UpdateEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", TimeoutMS: 1000, InputLimit: 1000}}}
  217. newTokenReq := base
  218. newTokenReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
  219. newTokenReq.Endpoints[0].Token = "fresh-token"
  220. _, err := manager.buildNextStorage(current, newTokenReq, 9)
  221. require.Error(t, err)
  222. require.Equal(t, ErrorCodeEncryptionKeyRequired, infraerrors.Reason(err))
  223. preserved, err := manager.buildNextStorage(current, base, 9)
  224. require.NoError(t, err)
  225. require.Equal(t, "enc:old", preserved.Endpoints[0].TokenCiphertext)
  226. clearedReq := base
  227. clearedReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
  228. clearedReq.Endpoints[0].ClearToken = true
  229. cleared, err := manager.buildNextStorage(current, clearedReq, 9)
  230. require.NoError(t, err)
  231. require.Empty(t, cleared.Endpoints[0].TokenCiphertext)
  232. }
  233. func TestEffectiveModeTruthTable(t *testing.T) {
  234. tests := []struct {
  235. risk, enabled, blocking bool
  236. want Mode
  237. }{
  238. {false, false, false, ModeOff}, {false, true, true, ModeOff}, {true, false, false, ModeOff},
  239. {true, true, false, ModeAsync}, {true, true, true, ModeBlocking},
  240. }
  241. for _, tt := range tests {
  242. cfg := ActiveConfig{RiskControlEnabled: tt.risk, Enabled: tt.enabled, BlockingEnabled: tt.blocking}
  243. require.Equal(t, tt.want, cfg.EffectiveMode())
  244. }
  245. }
  246. func TestConfigManagerColdStartOnlyFailsClosedForExplicitBlockingIntent(t *testing.T) {
  247. manager := &ConfigManager{}
  248. manager.observeExpectedState(`{"enabled":true,"blocking_enabled":false,"config_version":42}`, true)
  249. require.Equal(t, int64(42), manager.expected.Load())
  250. require.Equal(t, ModeOff, manager.EffectiveMode(), "an async config version must not imply blocking")
  251. require.False(t, manager.BlockingActivationDegraded())
  252. manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":43}`, false)
  253. require.Equal(t, ModeOff, manager.EffectiveMode(), "the global risk-control switch still gates blocking")
  254. manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":44}`, true)
  255. require.Equal(t, ModeBlocking, manager.EffectiveMode())
  256. require.True(t, manager.BlockingActivationDegraded())
  257. manager.observeExpectedState(`{"enabled":true`, true)
  258. require.Equal(t, ModeBlocking, manager.EffectiveMode(), "undecodable storage must not erase the last known strict intent")
  259. }
  260. func TestConfigManagerStaleWeakerSnapshotFailsClosedWhenBlockingExpected(t *testing.T) {
  261. manager := &ConfigManager{}
  262. async := ActiveConfig{RiskControlEnabled: true, Enabled: true, BlockingEnabled: false, ConfigVersion: 1}
  263. manager.snapshot.Store(&activeConfigSnapshot{active: async, storage: DefaultStorageConfig(), loadedAt: fixedClock{}.Now()})
  264. manager.expected.Store(2)
  265. manager.expectedBlocking.Store(true)
  266. require.True(t, manager.BlockingActivationDegraded())
  267. require.Equal(t, ModeBlocking, manager.EffectiveMode())
  268. service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)}
  269. decision, err := service.Evaluate(context.Background(), Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`)})
  270. require.Error(t, err)
  271. require.Nil(t, decision)
  272. var guardErr *GuardError
  273. require.ErrorAs(t, err, &guardErr)
  274. require.Equal(t, ErrorCodeUnavailable, guardErr.Code)
  275. }
  276. type errorSettingRepository struct{ staticSettingRepository }
  277. func (errorSettingRepository) GetMultiple(context.Context, []string) (map[string]string, error) {
  278. return nil, errors.New("settings unavailable")
  279. }
  280. type switchableSettingRepository struct {
  281. staticSettingRepository
  282. loadErr error
  283. }
  284. func (r *switchableSettingRepository) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
  285. if r.loadErr != nil {
  286. return nil, r.loadErr
  287. }
  288. return r.staticSettingRepository.GetMultiple(ctx, keys)
  289. }
  290. func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *testing.T) {
  291. // Settings unavailable and no prior blocking intent: stay ModeOff so the
  292. // gateway remains usable and admins can still disable/configure Prompt Audit.
  293. manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}, testTotpKeyConfig())
  294. err := manager.Start(context.Background())
  295. require.Error(t, err)
  296. require.True(t, manager.configUntrusted.Load())
  297. require.False(t, manager.BlockingActivationDegraded())
  298. require.Equal(t, ModeOff, manager.EffectiveMode())
  299. service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)}
  300. decision, evalErr := service.Evaluate(context.Background(), Request{
  301. Protocol: "openai_chat_completions",
  302. Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
  303. })
  304. require.NoError(t, evalErr)
  305. require.NotNil(t, decision)
  306. require.Equal(t, DecisionAllow, decision.Kind)
  307. require.NoError(t, manager.Shutdown(context.Background()))
  308. }
  309. func TestConfigManagerStartupLoadFailureFailsClosedWhenBlockingIntended(t *testing.T) {
  310. manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}, testTotpKeyConfig())
  311. // Simulate intent observed before a later load failure (e.g. decrypt error).
  312. manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":3}`, true)
  313. manager.markConfigUntrusted()
  314. require.True(t, manager.BlockingActivationDegraded())
  315. require.Equal(t, ModeBlocking, manager.EffectiveMode())
  316. service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)}
  317. decision, err := service.Evaluate(context.Background(), Request{
  318. Protocol: "openai_chat_completions",
  319. Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
  320. })
  321. require.Error(t, err)
  322. require.Nil(t, decision)
  323. var guardErr *GuardError
  324. require.ErrorAs(t, err, &guardErr)
  325. require.Equal(t, ErrorCodeUnavailable, guardErr.Code)
  326. }
  327. func TestConfigManagerUntrustedClearsOnSuccessfulDisable(t *testing.T) {
  328. // After a degraded fail-closed period, saving disabled config must restore ModeOff.
  329. manager := &ConfigManager{encryptor: prefixEncryptor{}, clock: fixedClock{}}
  330. manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":5}`, true)
  331. manager.markConfigUntrusted()
  332. require.Equal(t, ModeBlocking, manager.EffectiveMode())
  333. // Install a trusted disabled snapshot the same way Save does after commit.
  334. disabled := DefaultStorageConfig()
  335. disabled.ConfigVersion = 6
  336. disabled.Enabled = false
  337. disabled.BlockingEnabled = false
  338. active, err := ActiveFromStorage(disabled, true, manager.encryptor)
  339. require.NoError(t, err)
  340. manager.expected.Store(disabled.ConfigVersion)
  341. manager.expectedBlocking.Store(false)
  342. manager.snapshot.Store(&activeConfigSnapshot{storage: disabled, active: active, loadedAt: manager.clock.Now()})
  343. manager.configUntrusted.Store(false)
  344. require.False(t, manager.BlockingActivationDegraded())
  345. require.Equal(t, ModeOff, manager.EffectiveMode())
  346. service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)}
  347. decision, evalErr := service.Evaluate(context.Background(), Request{
  348. Protocol: "openai_chat_completions",
  349. Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
  350. })
  351. require.NoError(t, evalErr)
  352. require.Equal(t, DecisionAllow, decision.Kind)
  353. }
  354. func TestConfigManagerUntrustedWithoutBlockingDoesNotForceBlockingMode(t *testing.T) {
  355. manager := &ConfigManager{}
  356. manager.observeExpectedState(`{"enabled":true,"blocking_enabled":false,"config_version":2}`, true)
  357. manager.markConfigUntrusted()
  358. require.False(t, manager.expectedBlocking.Load())
  359. require.False(t, manager.BlockingActivationDegraded())
  360. require.Equal(t, ModeOff, manager.EffectiveMode(), "async intent + untrusted must not force blocking unavailable")
  361. }
  362. func TestParseLegacyConfigDefaultsMissingFieldsWithoutEnablingBlocking(t *testing.T) {
  363. storage, err := ParseStorageConfig(`{"enabled":false,"config_version":9}`)
  364. require.NoError(t, err)
  365. require.False(t, storage.BlockingEnabled)
  366. require.Equal(t, "priority", storage.Strategy)
  367. require.Equal(t, DefaultWorkerCount, storage.WorkerCount)
  368. require.Equal(t, DefaultQueueCapacity, storage.QueueCapacity)
  369. require.Equal(t, AllScannerIDs, storage.Scanners)
  370. require.True(t, storage.AllGroups)
  371. }
  372. func TestUpdateConfigStrictBoundsAndKnownValues(t *testing.T) {
  373. valid := promptAuditUpdateRequest(1, 1, "")
  374. require.NoError(t, validateUpdateConfigRequest(valid))
  375. tests := []struct {
  376. name string
  377. mutate func(*UpdateConfigRequest)
  378. reason string
  379. }{
  380. {name: "strategy", mutate: func(req *UpdateConfigRequest) { req.Strategy = "round_robin" }, reason: "prompt_audit_invalid_strategy"},
  381. {name: "worker low", mutate: func(req *UpdateConfigRequest) { req.WorkerCount = 0 }, reason: "prompt_audit_invalid_worker_count"},
  382. {name: "worker high", mutate: func(req *UpdateConfigRequest) { req.WorkerCount = MaxWorkerCount + 1 }, reason: "prompt_audit_invalid_worker_count"},
  383. {name: "capacity low", mutate: func(req *UpdateConfigRequest) { req.QueueCapacity = 0 }, reason: "prompt_audit_invalid_queue_capacity"},
  384. {name: "capacity high", mutate: func(req *UpdateConfigRequest) { req.QueueCapacity = MaxQueueCapacity + 1 }, reason: "prompt_audit_invalid_queue_capacity"},
  385. {name: "unknown scanner", mutate: func(req *UpdateConfigRequest) { req.Scanners = []string{"made_up"} }, reason: "prompt_audit_invalid_scanner"},
  386. {name: "group required", mutate: func(req *UpdateConfigRequest) { req.AllGroups = false; req.GroupIDs = nil }, reason: "prompt_audit_groups_required"},
  387. {name: "group positive", mutate: func(req *UpdateConfigRequest) { req.AllGroups = false; req.GroupIDs = []int64{0} }, reason: "prompt_audit_invalid_group"},
  388. {name: "timeout low", mutate: func(req *UpdateConfigRequest) { req.Endpoints[0].TimeoutMS = MinTimeoutMS - 1 }, reason: "prompt_audit_invalid_timeout"},
  389. {name: "timeout high", mutate: func(req *UpdateConfigRequest) { req.Endpoints[0].TimeoutMS = MaxTimeoutMS + 1 }, reason: "prompt_audit_invalid_timeout"},
  390. {name: "input low", mutate: func(req *UpdateConfigRequest) { req.Endpoints[0].InputLimit = MinInputLimit - 1 }, reason: "prompt_audit_invalid_input_limit"},
  391. {name: "input high", mutate: func(req *UpdateConfigRequest) { req.Endpoints[0].InputLimit = MaxInputLimit + 1 }, reason: "prompt_audit_invalid_input_limit"},
  392. }
  393. for _, tt := range tests {
  394. t.Run(tt.name, func(t *testing.T) {
  395. req := valid
  396. req.Scanners = append([]string(nil), valid.Scanners...)
  397. req.GroupIDs = append([]int64(nil), valid.GroupIDs...)
  398. req.Endpoints = append([]UpdateEndpoint(nil), valid.Endpoints...)
  399. tt.mutate(&req)
  400. err := validateUpdateConfigRequest(req)
  401. require.Error(t, err)
  402. require.Equal(t, tt.reason, infraerrors.Reason(err))
  403. })
  404. }
  405. }
  406. // Regression coverage for issue #5732: refreshLoop reloads every 5s, so
  407. // config_loaded must stay a change signal instead of ~17k identical lines a
  408. // day, while still reporting the first load, real config changes and a
  409. // recovery from a failed reload.
  410. func TestConfigLoadedIsLoggedOnlyWhenSomethingChanged(t *testing.T) {
  411. storage := DefaultStorageConfig()
  412. storage.ConfigVersion = 4
  413. raw, err := json.Marshal(storage)
  414. require.NoError(t, err)
  415. repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{
  416. SettingKeyPromptAuditConfig: string(raw),
  417. SettingKeyRiskControl: "false",
  418. }}}
  419. manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
  420. var output bytes.Buffer
  421. previous := slog.Default()
  422. slog.SetDefault(slog.New(slog.NewJSONHandler(&output, nil)))
  423. t.Cleanup(func() { slog.SetDefault(previous) })
  424. loadedCount := func() int { return strings.Count(output.String(), EventConfigLoaded) }
  425. require.NoError(t, manager.Reload(context.Background()))
  426. require.Equal(t, 1, loadedCount(), "the first successful load must be logged")
  427. require.NoError(t, manager.Reload(context.Background()))
  428. require.NoError(t, manager.Reload(context.Background()))
  429. require.Equal(t, 1, loadedCount(), "TTL refreshes of an unchanged config must stay silent")
  430. repository.values[SettingKeyRiskControl] = "true"
  431. require.NoError(t, manager.Reload(context.Background()))
  432. require.Equal(t, 2, loadedCount(), "flipping the global risk control gate must be logged")
  433. storage.ConfigVersion = 5
  434. raw, err = json.Marshal(storage)
  435. require.NoError(t, err)
  436. repository.values[SettingKeyPromptAuditConfig] = string(raw)
  437. require.NoError(t, manager.Reload(context.Background()))
  438. require.Equal(t, 3, loadedCount(), "a new config version must be logged")
  439. repository.loadErr = errors.New("settings unavailable")
  440. require.Error(t, manager.Reload(context.Background()))
  441. require.Equal(t, 3, loadedCount(), "a failed reload must not claim a load")
  442. repository.loadErr = nil
  443. require.NoError(t, manager.Reload(context.Background()))
  444. require.Equal(t, 4, loadedCount(), "recovering from a failed reload must be visible")
  445. }