prompt_config_integration_test.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. package securityaudit
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "errors"
  7. "os"
  8. "strings"
  9. "sync"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  14. "github.com/Wei-Shaw/sub2api/internal/repository"
  15. "github.com/Wei-Shaw/sub2api/internal/service"
  16. "github.com/lib/pq"
  17. "github.com/redis/go-redis/v9"
  18. "github.com/stretchr/testify/require"
  19. )
  20. const promptAuditRedisTestEnv = "PROMPT_AUDIT_TEST_REDIS_ADDR"
  21. type postgresPromptAuditSettingRepository struct{ db *sql.DB }
  22. func (r postgresPromptAuditSettingRepository) Get(ctx context.Context, key string) (*service.Setting, error) {
  23. var value string
  24. var updated time.Time
  25. err := r.db.QueryRowContext(ctx, `SELECT value,updated_at FROM settings WHERE key=$1`, key).Scan(&value, &updated)
  26. if errors.Is(err, sql.ErrNoRows) {
  27. return nil, service.ErrSettingNotFound
  28. }
  29. if err != nil {
  30. return nil, err
  31. }
  32. return &service.Setting{Key: key, Value: value, UpdatedAt: updated}, nil
  33. }
  34. func (r postgresPromptAuditSettingRepository) GetValue(ctx context.Context, key string) (string, error) {
  35. setting, err := r.Get(ctx, key)
  36. if err != nil {
  37. return "", err
  38. }
  39. return setting.Value, nil
  40. }
  41. func (r postgresPromptAuditSettingRepository) Set(ctx context.Context, key, value string) error {
  42. _, err := r.db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES($1,$2,NOW())
  43. ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,updated_at=EXCLUDED.updated_at`, key, value)
  44. return err
  45. }
  46. func (r postgresPromptAuditSettingRepository) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
  47. result := make(map[string]string, len(keys))
  48. for _, key := range keys {
  49. result[key] = ""
  50. }
  51. rows, err := r.db.QueryContext(ctx, `SELECT key,value FROM settings WHERE key=ANY($1)`, pq.Array(keys))
  52. if err != nil {
  53. return nil, err
  54. }
  55. defer func() { _ = rows.Close() }()
  56. for rows.Next() {
  57. var key, value string
  58. if err := rows.Scan(&key, &value); err != nil {
  59. return nil, err
  60. }
  61. result[key] = value
  62. }
  63. return result, rows.Err()
  64. }
  65. func (r postgresPromptAuditSettingRepository) SetMultiple(ctx context.Context, values map[string]string) error {
  66. tx, err := r.db.BeginTx(ctx, nil)
  67. if err != nil {
  68. return err
  69. }
  70. defer func() { _ = tx.Rollback() }()
  71. for key, value := range values {
  72. if _, err := tx.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES($1,$2,NOW())
  73. ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,updated_at=EXCLUDED.updated_at`, key, value); err != nil {
  74. return err
  75. }
  76. }
  77. return tx.Commit()
  78. }
  79. func (r postgresPromptAuditSettingRepository) GetAll(ctx context.Context) (map[string]string, error) {
  80. rows, err := r.db.QueryContext(ctx, `SELECT key,value FROM settings`)
  81. if err != nil {
  82. return nil, err
  83. }
  84. defer func() { _ = rows.Close() }()
  85. result := map[string]string{}
  86. for rows.Next() {
  87. var key, value string
  88. if err := rows.Scan(&key, &value); err != nil {
  89. return nil, err
  90. }
  91. result[key] = value
  92. }
  93. return result, rows.Err()
  94. }
  95. func (r postgresPromptAuditSettingRepository) Delete(ctx context.Context, key string) error {
  96. _, err := r.db.ExecContext(ctx, `DELETE FROM settings WHERE key=$1`, key)
  97. return err
  98. }
  99. func promptAuditTestEncryptor(t *testing.T) service.SecretEncryptor {
  100. t.Helper()
  101. encryptor, err := repository.NewAESEncryptor(&config.Config{Totp: config.TotpConfig{EncryptionKey: strings.Repeat("42", 32)}})
  102. require.NoError(t, err)
  103. return encryptor
  104. }
  105. func promptAuditUpdateRequest(version int64, workerCount int, token string) UpdateConfigRequest {
  106. return UpdateConfigRequest{
  107. ExpectedConfigVersion: version, Enabled: true, BlockingEnabled: false, StorePassEvents: false,
  108. Strategy: "priority", WorkerCount: workerCount, QueueCapacity: 64, Scanners: []string{"pii", "jailbreak"},
  109. AllGroups: true, Endpoints: []UpdateEndpoint{{
  110. ID: "guard-one", Name: "Guard One", Protocol: "openai_compatible",
  111. BaseURL: "http://127.0.0.1:18080", Model: "", Token: token,
  112. TimeoutMS: 1000, InputLimit: 1024, Enabled: true,
  113. }},
  114. }
  115. }
  116. func waitForConfigVersion(t *testing.T, manager *ConfigManager, version int64, timeout time.Duration) {
  117. t.Helper()
  118. require.Eventually(t, func() bool {
  119. active, ok := manager.Active()
  120. return ok && active.ConfigVersion == version
  121. }, timeout, 20*time.Millisecond)
  122. }
  123. func TestPromptAuditConfigCASSecretRoundTripInvalidationAndTTL(t *testing.T) {
  124. redisAddress := strings.TrimSpace(os.Getenv(promptAuditRedisTestEnv))
  125. if redisAddress == "" {
  126. t.Skip(promptAuditRedisTestEnv + " is not set")
  127. }
  128. db := openPromptAuditIntegrationDB(t)
  129. settingRepo := postgresPromptAuditSettingRepository{db: db}
  130. require.NoError(t, settingRepo.Set(context.Background(), SettingKeyRiskControl, "true"))
  131. encryptor := promptAuditTestEncryptor(t)
  132. redisClient := redis.NewClient(&redis.Options{Addr: redisAddress})
  133. t.Cleanup(func() { require.NoError(t, redisClient.Close()) })
  134. require.NoError(t, redisClient.Ping(context.Background()).Err())
  135. managerOne := NewConfigManager(db, settingRepo, redisClient, encryptor, testTotpKeyConfig())
  136. managerTwo := NewConfigManager(db, settingRepo, redisClient, encryptor, testTotpKeyConfig())
  137. ctx, cancel := context.WithCancel(context.Background())
  138. t.Cleanup(cancel)
  139. require.NoError(t, managerOne.Start(ctx))
  140. require.NoError(t, managerTwo.Start(ctx))
  141. t.Cleanup(func() {
  142. require.NoError(t, managerOne.Shutdown(context.Background()))
  143. require.NoError(t, managerTwo.Shutdown(context.Background()))
  144. })
  145. require.Eventually(t, func() bool {
  146. return redisClient.PubSubNumSub(context.Background(), ConfigInvalidationChannel).Val()[ConfigInvalidationChannel] >= 2
  147. }, 2*time.Second, 20*time.Millisecond)
  148. const canary = "GUARD_TOKEN_CANARY_SECRET_4_CONFIG"
  149. public, err := managerOne.Save(context.Background(), promptAuditUpdateRequest(1, 1, canary), 101)
  150. require.NoError(t, err)
  151. require.Equal(t, int64(2), public.ConfigVersion)
  152. require.True(t, public.Endpoints[0].HasToken)
  153. publicJSON, err := json.Marshal(public)
  154. require.NoError(t, err)
  155. require.NotContains(t, string(publicJSON), canary)
  156. waitForConfigVersion(t, managerTwo, 2, 2*time.Second)
  157. raw, err := settingRepo.GetValue(context.Background(), SettingKeyPromptAuditConfig)
  158. require.NoError(t, err)
  159. require.NotContains(t, raw, canary)
  160. stored, err := ParseStorageConfig(raw)
  161. require.NoError(t, err)
  162. require.NotEmpty(t, stored.Endpoints[0].TokenCiphertext)
  163. plain, err := encryptor.Decrypt(stored.Endpoints[0].TokenCiphertext)
  164. require.NoError(t, err)
  165. require.Equal(t, canary, plain)
  166. require.NotContains(t, stored.ChangeSummary, canary)
  167. require.NotContains(t, stored.ChangeSummary, stored.Endpoints[0].BaseURL)
  168. type saveResult struct {
  169. config PublicConfig
  170. err error
  171. }
  172. start := make(chan struct{})
  173. results := make(chan saveResult, 2)
  174. var wg sync.WaitGroup
  175. for index, manager := range []*ConfigManager{managerOne, managerTwo} {
  176. wg.Add(1)
  177. go func(index int, manager *ConfigManager) {
  178. defer wg.Done()
  179. <-start
  180. cfg, saveErr := manager.Save(context.Background(), promptAuditUpdateRequest(2, index+2, ""), int64(201+index))
  181. results <- saveResult{config: cfg, err: saveErr}
  182. }(index, manager)
  183. }
  184. close(start)
  185. wg.Wait()
  186. close(results)
  187. succeeded, conflicted := 0, 0
  188. for result := range results {
  189. if result.err == nil {
  190. succeeded++
  191. require.Equal(t, int64(3), result.config.ConfigVersion)
  192. continue
  193. }
  194. conflicted++
  195. require.Equal(t, ErrorCodeConfigConflict, infraerrors.Reason(result.err))
  196. }
  197. require.Equal(t, 1, succeeded)
  198. require.Equal(t, 1, conflicted)
  199. waitForConfigVersion(t, managerOne, 3, 2*time.Second)
  200. waitForConfigVersion(t, managerTwo, 3, 2*time.Second)
  201. // A manager without Redis subscriptions must still converge through the
  202. // bounded five-second refresh loop.
  203. ttlManager := NewConfigManager(db, settingRepo, nil, encryptor, testTotpKeyConfig())
  204. require.NoError(t, ttlManager.Start(ctx))
  205. t.Cleanup(func() { require.NoError(t, ttlManager.Shutdown(context.Background())) })
  206. waitForConfigVersion(t, ttlManager, 3, time.Second)
  207. updated, err := managerOne.Save(context.Background(), promptAuditUpdateRequest(3, 5, ""), 301)
  208. require.NoError(t, err)
  209. require.Equal(t, int64(4), updated.ConfigVersion)
  210. waitForConfigVersion(t, ttlManager, 4, 7*time.Second)
  211. // Redis publication failure is observable degradation, not a rollback of a
  212. // successfully committed PostgreSQL config.
  213. deadRedis := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1", MaxRetries: 0, DialTimeout: 30 * time.Millisecond, ReadTimeout: 30 * time.Millisecond, WriteTimeout: 30 * time.Millisecond})
  214. t.Cleanup(func() { _ = deadRedis.Close() })
  215. degraded := NewConfigManager(db, settingRepo, deadRedis, encryptor, testTotpKeyConfig())
  216. require.NoError(t, degraded.Reload(context.Background()))
  217. degradedSaved, err := degraded.Save(context.Background(), promptAuditUpdateRequest(4, 6, ""), 401)
  218. require.NoError(t, err)
  219. require.Equal(t, int64(5), degradedSaved.ConfigVersion)
  220. active, ok := degraded.Active()
  221. require.True(t, ok)
  222. require.Equal(t, int64(5), active.ConfigVersion)
  223. }