account_handler_duplicate_test.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. //go:build unit
  2. package admin
  3. import (
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "net/http"
  8. "net/http/httptest"
  9. "sync/atomic"
  10. "testing"
  11. "time"
  12. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  13. "github.com/Wei-Shaw/sub2api/internal/service"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. )
  17. type duplicateAccountAdminServiceStub struct {
  18. service.AdminService
  19. account *service.Account
  20. calls int
  21. recoverCalls int
  22. accountID int64
  23. actorScope string
  24. operationKey string
  25. recoverScope string
  26. recoverKey string
  27. recoverErr error
  28. created bool
  29. }
  30. type blockingDuplicateAdminServiceStub struct {
  31. service.AdminService
  32. account *service.Account
  33. started chan struct{}
  34. release chan struct{}
  35. calls atomic.Int32
  36. recoverCalls atomic.Int32
  37. recoverErr error
  38. }
  39. type failOnceMarkSucceededRepo struct {
  40. *memoryIdempotencyRepoStub
  41. failNext bool
  42. }
  43. func (r *failOnceMarkSucceededRepo) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
  44. if r.failNext {
  45. r.failNext = false
  46. return errors.New("mark succeeded failed")
  47. }
  48. return r.memoryIdempotencyRepoStub.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
  49. }
  50. func (s *duplicateAccountAdminServiceStub) DuplicateAccount(_ context.Context, accountID int64, actorScope, operationKey string) (*service.Account, error) {
  51. s.calls++
  52. s.accountID = accountID
  53. s.actorScope = actorScope
  54. s.operationKey = operationKey
  55. s.created = true
  56. return s.account, nil
  57. }
  58. func (s *duplicateAccountAdminServiceStub) RecoverDuplicateAccount(_ context.Context, _ int64, actorScope, operationKey string) (*service.Account, error) {
  59. s.recoverCalls++
  60. s.recoverScope = actorScope
  61. s.recoverKey = operationKey
  62. if s.recoverErr != nil {
  63. return nil, s.recoverErr
  64. }
  65. if !s.created {
  66. return nil, nil
  67. }
  68. return s.account, nil
  69. }
  70. func (s *blockingDuplicateAdminServiceStub) DuplicateAccount(_ context.Context, _ int64, _, _ string) (*service.Account, error) {
  71. s.calls.Add(1)
  72. close(s.started)
  73. <-s.release
  74. return s.account, nil
  75. }
  76. func (s *blockingDuplicateAdminServiceStub) RecoverDuplicateAccount(_ context.Context, _ int64, _, _ string) (*service.Account, error) {
  77. s.recoverCalls.Add(1)
  78. return nil, s.recoverErr
  79. }
  80. func setupDuplicateAccountRouter(t *testing.T, svc service.AdminService) *gin.Engine {
  81. t.Helper()
  82. previousCoordinator := service.DefaultIdempotencyCoordinator()
  83. service.SetDefaultIdempotencyCoordinator(nil)
  84. t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
  85. gin.SetMode(gin.TestMode)
  86. router := gin.New()
  87. router.Use(func(c *gin.Context) {
  88. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
  89. c.Next()
  90. })
  91. handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  92. router.POST("/api/v1/admin/accounts/:id/duplicate", handler.Duplicate)
  93. return router
  94. }
  95. func TestDuplicateAccountHandlerRedactsCredentials(t *testing.T) {
  96. svc := &duplicateAccountAdminServiceStub{
  97. account: &service.Account{
  98. ID: 43,
  99. Name: "primary (Copy)",
  100. Platform: service.PlatformAnthropic,
  101. Type: service.AccountTypeAPIKey,
  102. Status: service.StatusActive,
  103. Schedulable: false,
  104. Credentials: map[string]any{"api_key": "top-secret-key"},
  105. },
  106. }
  107. router := setupDuplicateAccountRouter(t, svc)
  108. recorder := httptest.NewRecorder()
  109. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
  110. router.ServeHTTP(recorder, request)
  111. require.Equal(t, http.StatusOK, recorder.Code)
  112. require.Equal(t, 1, svc.calls)
  113. require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`)
  114. require.NotContains(t, recorder.Body.String(), "top-secret-key")
  115. var responseBody struct {
  116. Data struct {
  117. Credentials map[string]any `json:"credentials"`
  118. Schedulable bool `json:"schedulable"`
  119. } `json:"data"`
  120. }
  121. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &responseBody))
  122. require.Empty(t, responseBody.Data.Credentials)
  123. require.False(t, responseBody.Data.Schedulable)
  124. }
  125. func TestDuplicateAccountHandlerRejectsInvalidID(t *testing.T) {
  126. svc := &duplicateAccountAdminServiceStub{}
  127. router := setupDuplicateAccountRouter(t, svc)
  128. recorder := httptest.NewRecorder()
  129. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/not-a-number/duplicate", nil)
  130. router.ServeHTTP(recorder, request)
  131. require.Equal(t, http.StatusBadRequest, recorder.Code)
  132. require.Zero(t, svc.calls)
  133. }
  134. func TestDuplicateAccountHandlerReplaysSameIdempotencyKey(t *testing.T) {
  135. svc := &duplicateAccountAdminServiceStub{
  136. account: &service.Account{
  137. ID: 43,
  138. Name: "primary (Copy)",
  139. Platform: service.PlatformAnthropic,
  140. Type: service.AccountTypeAPIKey,
  141. Status: service.StatusActive,
  142. Schedulable: false,
  143. },
  144. }
  145. router := setupDuplicateAccountRouter(t, svc)
  146. repo := newMemoryIdempotencyRepoStub()
  147. service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
  148. call := func() *httptest.ResponseRecorder {
  149. recorder := httptest.NewRecorder()
  150. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
  151. request.Header.Set("Idempotency-Key", "duplicate-account-42")
  152. router.ServeHTTP(recorder, request)
  153. return recorder
  154. }
  155. first := call()
  156. second := call()
  157. require.Equal(t, http.StatusOK, first.Code)
  158. require.Equal(t, http.StatusOK, second.Code)
  159. require.Equal(t, 1, svc.calls)
  160. require.Equal(t, int64(42), svc.accountID)
  161. require.Equal(t, "admin:77", svc.actorScope)
  162. require.Equal(t, "duplicate-account-42", svc.operationKey)
  163. require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
  164. }
  165. func TestDuplicateAccountHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
  166. svc := &duplicateAccountAdminServiceStub{
  167. account: &service.Account{
  168. ID: 43,
  169. Name: "primary (Copy)",
  170. Platform: service.PlatformAnthropic,
  171. Type: service.AccountTypeAPIKey,
  172. Status: service.StatusActive,
  173. Schedulable: false,
  174. },
  175. }
  176. router := setupDuplicateAccountRouter(t, svc)
  177. repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
  178. service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
  179. call := func() *httptest.ResponseRecorder {
  180. recorder := httptest.NewRecorder()
  181. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
  182. request.Header.Set("Idempotency-Key", "duplicate-account-42-recovery")
  183. router.ServeHTTP(recorder, request)
  184. return recorder
  185. }
  186. first := call()
  187. second := call()
  188. require.Equal(t, http.StatusOK, first.Code)
  189. require.Equal(t, http.StatusOK, second.Code)
  190. require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
  191. require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
  192. require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect")
  193. require.Equal(t, 2, svc.recoverCalls)
  194. require.Equal(t, "admin:77", svc.recoverScope)
  195. require.Equal(t, "duplicate-account-42-recovery", svc.recoverKey)
  196. require.Contains(t, second.Body.String(), `"id":43`)
  197. }
  198. func TestDuplicateAccountHandlerPreservesIdempotencyErrorWhenRecoveryLookupFails(t *testing.T) {
  199. svc := &duplicateAccountAdminServiceStub{
  200. account: &service.Account{
  201. ID: 43,
  202. Name: "primary (Copy)",
  203. Platform: service.PlatformAnthropic,
  204. Type: service.AccountTypeAPIKey,
  205. Status: service.StatusActive,
  206. Schedulable: false,
  207. },
  208. recoverErr: errors.New("recovery database unavailable"),
  209. }
  210. router := setupDuplicateAccountRouter(t, svc)
  211. repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
  212. service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
  213. recorder := httptest.NewRecorder()
  214. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
  215. request.Header.Set("Idempotency-Key", "duplicate-account-42-recovery-error")
  216. router.ServeHTTP(recorder, request)
  217. require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
  218. require.Contains(t, recorder.Body.String(), "IDEMPOTENCY_STORE_UNAVAILABLE")
  219. require.Equal(t, 1, svc.calls)
  220. require.Equal(t, 1, svc.recoverCalls)
  221. require.Equal(t, "admin:77", svc.recoverScope)
  222. }
  223. func TestDuplicateAccountHandlerDoesNotReexecuteWhileOriginalIsProcessing(t *testing.T) {
  224. svc := &blockingDuplicateAdminServiceStub{
  225. account: &service.Account{
  226. ID: 43,
  227. Name: "primary (Copy)",
  228. Platform: service.PlatformAnthropic,
  229. Type: service.AccountTypeAPIKey,
  230. Status: service.StatusActive,
  231. Schedulable: false,
  232. },
  233. started: make(chan struct{}),
  234. release: make(chan struct{}),
  235. recoverErr: errors.New("recovery database unavailable"),
  236. }
  237. router := setupDuplicateAccountRouter(t, svc)
  238. service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig()))
  239. call := func() *httptest.ResponseRecorder {
  240. recorder := httptest.NewRecorder()
  241. request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
  242. request.Header.Set("Idempotency-Key", "duplicate-account-42-active")
  243. router.ServeHTTP(recorder, request)
  244. return recorder
  245. }
  246. firstDone := make(chan *httptest.ResponseRecorder, 1)
  247. go func() { firstDone <- call() }()
  248. <-svc.started
  249. second := call()
  250. require.Equal(t, http.StatusConflict, second.Code)
  251. require.Contains(t, second.Body.String(), "IDEMPOTENCY_IN_PROGRESS")
  252. require.Equal(t, int32(1), svc.calls.Load())
  253. require.Equal(t, int32(1), svc.recoverCalls.Load())
  254. close(svc.release)
  255. select {
  256. case first := <-firstDone:
  257. require.Equal(t, http.StatusOK, first.Code)
  258. case <-time.After(time.Second):
  259. t.Fatal("original duplicate request did not finish")
  260. }
  261. }