channel_monitor_duplicate_test.go 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "strings"
  7. "testing"
  8. "time"
  9. "unicode/utf8"
  10. "github.com/stretchr/testify/require"
  11. )
  12. type duplicateChannelMonitorRepoStub struct {
  13. ChannelMonitorRepository
  14. source *ChannelMonitor
  15. created []*ChannelMonitor
  16. byOperation map[string]*ChannelMonitor
  17. nextID int64
  18. }
  19. func (r *duplicateChannelMonitorRepoStub) GetByID(_ context.Context, id int64) (*ChannelMonitor, error) {
  20. if r.source == nil || r.source.ID != id {
  21. return nil, ErrChannelMonitorNotFound
  22. }
  23. return r.source, nil
  24. }
  25. func (r *duplicateChannelMonitorRepoStub) Create(_ context.Context, monitor *ChannelMonitor) error {
  26. r.nextID++
  27. monitor.ID = 100 + r.nextID
  28. monitor.CreatedAt = time.Date(2026, time.July, 16, 8, 0, 0, 0, time.UTC)
  29. monitor.UpdatedAt = monitor.CreatedAt
  30. stored := *monitor
  31. stored.ExtraModels = append([]string(nil), monitor.ExtraModels...)
  32. stored.ExtraHeaders = cloneStringMap(monitor.ExtraHeaders)
  33. stored.BodyOverride = mustCloneJSONMap(monitor.BodyOverride)
  34. if monitor.TemplateID != nil {
  35. templateID := *monitor.TemplateID
  36. stored.TemplateID = &templateID
  37. }
  38. r.created = append(r.created, &stored)
  39. if stored.DuplicateOperationID != "" {
  40. if r.byOperation == nil {
  41. r.byOperation = make(map[string]*ChannelMonitor)
  42. }
  43. r.byOperation[stored.DuplicateOperationID] = &stored
  44. }
  45. return nil
  46. }
  47. func (r *duplicateChannelMonitorRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*ChannelMonitor, error) {
  48. monitor := r.byOperation[operationID]
  49. if monitor == nil {
  50. return nil, nil
  51. }
  52. cloned := *monitor
  53. cloned.ExtraModels = append([]string(nil), monitor.ExtraModels...)
  54. cloned.ExtraHeaders = cloneStringMap(monitor.ExtraHeaders)
  55. cloned.BodyOverride = mustCloneJSONMap(monitor.BodyOverride)
  56. return &cloned, nil
  57. }
  58. func cloneStringMap(source map[string]string) map[string]string {
  59. if source == nil {
  60. return nil
  61. }
  62. cloned := make(map[string]string, len(source))
  63. for key, value := range source {
  64. cloned[key] = value
  65. }
  66. return cloned
  67. }
  68. func mustCloneJSONMap(source map[string]any) map[string]any {
  69. if source == nil {
  70. return nil
  71. }
  72. cloned, err := cloneChannelMonitorJSONMap(source)
  73. if err != nil {
  74. panic(err)
  75. }
  76. return cloned
  77. }
  78. type duplicateChannelMonitorEncryptor struct {
  79. decryptErr error
  80. encryptErr error
  81. }
  82. func (e *duplicateChannelMonitorEncryptor) Encrypt(plaintext string) (string, error) {
  83. if e.encryptErr != nil {
  84. return "", e.encryptErr
  85. }
  86. return "NEW:" + plaintext, nil
  87. }
  88. func (e *duplicateChannelMonitorEncryptor) Decrypt(ciphertext string) (string, error) {
  89. if e.decryptErr != nil {
  90. return "", e.decryptErr
  91. }
  92. if !strings.HasPrefix(ciphertext, "OLD:") && !strings.HasPrefix(ciphertext, "NEW:") {
  93. return "", errors.New("invalid ciphertext")
  94. }
  95. return strings.TrimPrefix(strings.TrimPrefix(ciphertext, "OLD:"), "NEW:"), nil
  96. }
  97. func TestDuplicateChannelMonitorCopiesConfigurationAndResetsRuntimeState(t *testing.T) {
  98. lastCheckedAt := time.Date(2026, time.July, 15, 7, 0, 0, 0, time.UTC)
  99. templateID := int64(9)
  100. source := &ChannelMonitor{
  101. ID: 42,
  102. Name: "primary",
  103. Provider: MonitorProviderOpenAI,
  104. APIMode: MonitorAPIModeResponses,
  105. Endpoint: "https://api.example.com",
  106. APIKey: "OLD:top-secret",
  107. PrimaryModel: "gpt-5.4-mini",
  108. ExtraModels: []string{"gpt-5.4", "gpt-5.3"},
  109. GroupName: "production",
  110. Enabled: true,
  111. IntervalSeconds: 90,
  112. JitterSeconds: 15,
  113. LastCheckedAt: &lastCheckedAt,
  114. CreatedBy: 4,
  115. CreatedAt: lastCheckedAt.Add(-time.Hour),
  116. UpdatedAt: lastCheckedAt,
  117. TemplateID: &templateID,
  118. ExtraHeaders: map[string]string{"User-Agent": "Codex"},
  119. BodyOverrideMode: MonitorBodyOverrideModeMerge,
  120. BodyOverride: map[string]any{
  121. "metadata": map[string]any{"source": "original"},
  122. },
  123. }
  124. repo := &duplicateChannelMonitorRepoStub{source: source}
  125. service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{})
  126. duplicate, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "copy-primary")
  127. require.NoError(t, err)
  128. require.Len(t, repo.created, 1)
  129. stored := repo.created[0]
  130. require.NotEqual(t, source.ID, duplicate.ID)
  131. require.Equal(t, "primary (Copy)", duplicate.Name)
  132. require.Equal(t, source.Provider, duplicate.Provider)
  133. require.Equal(t, source.APIMode, duplicate.APIMode)
  134. require.Equal(t, source.Endpoint, duplicate.Endpoint)
  135. require.Equal(t, "top-secret", duplicate.APIKey)
  136. require.Equal(t, "NEW:top-secret", stored.APIKey)
  137. require.Equal(t, source.PrimaryModel, duplicate.PrimaryModel)
  138. require.Equal(t, source.ExtraModels, duplicate.ExtraModels)
  139. require.Equal(t, source.GroupName, duplicate.GroupName)
  140. require.Equal(t, source.IntervalSeconds, duplicate.IntervalSeconds)
  141. require.Equal(t, source.JitterSeconds, duplicate.JitterSeconds)
  142. require.Equal(t, source.TemplateID, duplicate.TemplateID)
  143. require.Equal(t, source.ExtraHeaders, duplicate.ExtraHeaders)
  144. require.Equal(t, source.BodyOverrideMode, duplicate.BodyOverrideMode)
  145. require.Equal(t, source.BodyOverride, duplicate.BodyOverride)
  146. require.False(t, duplicate.Enabled)
  147. require.Nil(t, duplicate.LastCheckedAt)
  148. require.Equal(t, int64(77), duplicate.CreatedBy)
  149. require.False(t, duplicate.APIKeyDecryptFailed)
  150. require.NotEmpty(t, duplicate.DuplicateOperationID)
  151. duplicate.ExtraModels[0] = "changed"
  152. duplicate.ExtraHeaders["User-Agent"] = "changed"
  153. duplicate.BodyOverride["metadata"].(map[string]any)["source"] = "changed"
  154. *duplicate.TemplateID = 10
  155. require.Equal(t, []string{"gpt-5.4", "gpt-5.3"}, source.ExtraModels)
  156. require.Equal(t, "Codex", source.ExtraHeaders["User-Agent"])
  157. require.Equal(t, "original", source.BodyOverride["metadata"].(map[string]any)["source"])
  158. require.Equal(t, int64(9), *source.TemplateID)
  159. require.Equal(t, "OLD:top-secret", source.APIKey)
  160. require.True(t, source.Enabled)
  161. require.Equal(t, &lastCheckedAt, source.LastCheckedAt)
  162. }
  163. func TestDuplicateChannelMonitorNamePreservesSuffixWithinSchemaLimit(t *testing.T) {
  164. name := duplicateChannelMonitorName(strings.Repeat("界", 100))
  165. require.Equal(t, 100, utf8.RuneCountInString(name))
  166. require.True(t, strings.HasSuffix(name, " (Copy)"))
  167. }
  168. func TestDuplicateChannelMonitorRejectsUndecryptableAPIKey(t *testing.T) {
  169. source := &ChannelMonitor{ID: 42, Name: "broken", APIKey: "OLD:broken"}
  170. repo := &duplicateChannelMonitorRepoStub{source: source}
  171. service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{decryptErr: errors.New("wrong encryption key")})
  172. duplicate, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "copy-broken")
  173. require.Nil(t, duplicate)
  174. require.ErrorIs(t, err, ErrChannelMonitorAPIKeyDecryptFailed)
  175. require.Empty(t, repo.created)
  176. require.Equal(t, "OLD:broken", source.APIKey)
  177. }
  178. func TestDuplicateChannelMonitorRecoversCommittedCopyForSameOperation(t *testing.T) {
  179. source := &ChannelMonitor{
  180. ID: 42,
  181. Name: "primary",
  182. Provider: MonitorProviderOpenAI,
  183. APIMode: MonitorAPIModeResponses,
  184. Endpoint: "https://api.example.com",
  185. APIKey: "OLD:top-secret",
  186. PrimaryModel: "gpt-5.4-mini",
  187. IntervalSeconds: 60,
  188. BodyOverrideMode: MonitorBodyOverrideModeOff,
  189. }
  190. repo := &duplicateChannelMonitorRepoStub{source: source}
  191. service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{})
  192. first, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "stable-key")
  193. require.NoError(t, err)
  194. retry, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "stable-key")
  195. require.NoError(t, err)
  196. require.Len(t, repo.created, 1, "same operation must not create a second monitor")
  197. require.Equal(t, first.ID, retry.ID)
  198. require.Equal(t, "top-secret", retry.APIKey)
  199. require.Equal(t, first.DuplicateOperationID, retry.DuplicateOperationID)
  200. require.NotContains(t, retry.ExtraHeaders, ChannelMonitorDuplicateOperationIDMetadataKey)
  201. otherActor, err := service.Duplicate(context.Background(), source.ID, 88, "admin:88", "stable-key")
  202. require.NoError(t, err)
  203. require.NotEqual(t, first.ID, otherActor.ID)
  204. require.Len(t, repo.created, 2, "operation identity must include the actor scope")
  205. }
  206. func TestChannelMonitorDuplicateOperationMetadataKeyCannotBeSubmittedAsHeader(t *testing.T) {
  207. err := validateExtraHeaders(map[string]string{
  208. ChannelMonitorDuplicateOperationIDMetadataKey: "forged-operation",
  209. })
  210. require.Error(t, err)
  211. }