image_storage_settings_test.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "strings"
  8. "sync"
  9. "testing"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/stretchr/testify/require"
  12. )
  13. type stubSettingRepo struct {
  14. mu sync.Mutex
  15. values map[string]string
  16. }
  17. func newStubSettingRepo() *stubSettingRepo {
  18. return &stubSettingRepo{values: map[string]string{}}
  19. }
  20. func (r *stubSettingRepo) Get(context.Context, string) (*Setting, error) { return nil, nil }
  21. func (r *stubSettingRepo) GetValue(_ context.Context, key string) (string, error) {
  22. r.mu.Lock()
  23. defer r.mu.Unlock()
  24. return r.values[key], nil
  25. }
  26. func (r *stubSettingRepo) Set(_ context.Context, key, value string) error {
  27. r.mu.Lock()
  28. defer r.mu.Unlock()
  29. r.values[key] = value
  30. return nil
  31. }
  32. func (r *stubSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
  33. return map[string]string{}, nil
  34. }
  35. func (r *stubSettingRepo) SetMultiple(context.Context, map[string]string) error { return nil }
  36. func (r *stubSettingRepo) GetAll(context.Context) (map[string]string, error) {
  37. return map[string]string{}, nil
  38. }
  39. func (r *stubSettingRepo) Delete(context.Context, string) error { return nil }
  40. // reversibleEncryptor stands in for AES: prefixed so a test can tell ciphertext
  41. // from plaintext, and so decrypting a plaintext value fails like the real one.
  42. type reversibleEncryptor struct{}
  43. func (reversibleEncryptor) Encrypt(plaintext string) (string, error) {
  44. return "enc:" + plaintext, nil
  45. }
  46. func (reversibleEncryptor) Decrypt(ciphertext string) (string, error) {
  47. rest, ok := strings.CutPrefix(ciphertext, "enc:")
  48. if !ok {
  49. return "", errors.New("not encrypted")
  50. }
  51. return rest, nil
  52. }
  53. type recordingStorage struct{ saved []string }
  54. func (s *recordingStorage) Save(_ context.Context, key, _ string, _ []byte) (string, error) {
  55. s.saved = append(s.saved, key)
  56. return "https://cdn.example.com/" + key, nil
  57. }
  58. func newImageStorageFixture(t *testing.T, fallback config.ImageStorageConfig) (*ImageStorageSettingService, *stubSettingRepo, *[]config.ImageStorageConfig) {
  59. return newImageStorageFixtureWithKey(t, fallback, true)
  60. }
  61. func newImageStorageFixtureWithKey(t *testing.T, fallback config.ImageStorageConfig, encryptionKeyConfigured bool) (*ImageStorageSettingService, *stubSettingRepo, *[]config.ImageStorageConfig) {
  62. t.Helper()
  63. repo := newStubSettingRepo()
  64. encryptor := reversibleEncryptor{}
  65. backup := NewBackupService(repo, &config.Config{
  66. Totp: config.TotpConfig{EncryptionKeyConfigured: encryptionKeyConfigured},
  67. }, encryptor, nil, nil)
  68. var built []config.ImageStorageConfig
  69. factory := func(_ context.Context, cfg *config.ImageStorageConfig) (ImageStorage, error) {
  70. built = append(built, *cfg)
  71. return &recordingStorage{}, nil
  72. }
  73. return NewImageStorageSettingService(repo, encryptor, backup, factory, fallback), repo, &built
  74. }
  75. func seedBackupS3(t *testing.T, repo *stubSettingRepo, cfg BackupS3Config) {
  76. t.Helper()
  77. cfg.SecretAccessKey = "enc:" + cfg.SecretAccessKey
  78. data, err := json.Marshal(cfg)
  79. require.NoError(t, err)
  80. require.NoError(t, repo.Set(context.Background(), settingKeyBackupS3Config, string(data)))
  81. }
  82. // The admin switch must take effect without a restart: that is the entire point
  83. // of moving image_storage out of config.yaml (#4542).
  84. func TestImageStorageSettingsToggleTakesEffectWithoutRestart(t *testing.T) {
  85. svc, repo, built := newImageStorageFixture(t, config.ImageStorageConfig{})
  86. ctx := context.Background()
  87. seedBackupS3(t, repo, BackupS3Config{
  88. Endpoint: "https://acct.r2.cloudflarestorage.com", Region: "auto",
  89. Bucket: "backup-bucket", AccessKeyID: "ak", SecretAccessKey: "sk",
  90. Prefix: "backups/",
  91. })
  92. uploader, enabled := svc.resolve()
  93. require.False(t, enabled, "disabled until an admin turns it on")
  94. require.Nil(t, uploader)
  95. _, err := svc.Update(ctx, ImageStorageSettings{Enabled: true, ReuseBackupS3: true})
  96. require.NoError(t, err)
  97. uploader, enabled = svc.resolve()
  98. require.True(t, enabled, "saving the setting must enable the feature immediately")
  99. require.NotNil(t, uploader)
  100. _, err = svc.Update(ctx, ImageStorageSettings{Enabled: false, ReuseBackupS3: true})
  101. require.NoError(t, err)
  102. _, enabled = svc.resolve()
  103. require.False(t, enabled, "turning it back off must also apply immediately")
  104. require.Len(t, *built, 1, "the S3 client is built only when the feature is on")
  105. }
  106. func TestImageStorageSettingsReuseBackupCredentials(t *testing.T) {
  107. svc, repo, built := newImageStorageFixture(t, config.ImageStorageConfig{})
  108. ctx := context.Background()
  109. seedBackupS3(t, repo, BackupS3Config{
  110. Endpoint: "https://acct.r2.cloudflarestorage.com", Region: "wnam",
  111. Bucket: "backup-bucket", AccessKeyID: "backup-ak", SecretAccessKey: "backup-sk",
  112. Prefix: "backups/", ForcePathStyle: true,
  113. })
  114. _, err := svc.Update(ctx, ImageStorageSettings{Enabled: true, ReuseBackupS3: true, Prefix: "images"})
  115. require.NoError(t, err)
  116. _, enabled := svc.resolve()
  117. require.True(t, enabled)
  118. require.Len(t, *built, 1)
  119. got := (*built)[0]
  120. require.Equal(t, "https://acct.r2.cloudflarestorage.com", got.Endpoint)
  121. require.Equal(t, "wnam", got.Region)
  122. require.Equal(t, "backup-ak", got.AccessKeyID)
  123. require.Equal(t, "backup-sk", got.SecretAccessKey, "the backup secret must be decrypted before use")
  124. require.True(t, got.ForcePathStyle)
  125. require.Equal(t, "backup-bucket", got.Bucket, "an empty bucket falls back to the backup bucket")
  126. require.Equal(t, "images/", got.Prefix, "images stay under their own prefix so they never collide with backups/")
  127. // Reusing must not duplicate the secret into a second row.
  128. raw, err := repo.GetValue(ctx, settingKeyImageStorageConfig)
  129. require.NoError(t, err)
  130. require.NotContains(t, raw, "backup-sk")
  131. require.NotContains(t, raw, "enc:")
  132. }
  133. func TestImageStorageSettingsOwnCredentialsAreEncryptedAndMasked(t *testing.T) {
  134. svc, repo, built := newImageStorageFixture(t, config.ImageStorageConfig{})
  135. ctx := context.Background()
  136. saved, err := svc.Update(ctx, ImageStorageSettings{
  137. Enabled: true, Bucket: "my-images",
  138. Endpoint: "https://acct.r2.cloudflarestorage.com",
  139. AccessKeyID: "ak", SecretAccessKey: "super-secret",
  140. })
  141. require.NoError(t, err)
  142. require.Empty(t, saved.SecretAccessKey, "the response must never echo the secret back")
  143. raw, err := repo.GetValue(ctx, settingKeyImageStorageConfig)
  144. require.NoError(t, err)
  145. require.NotContains(t, raw, `"secret_access_key":"super-secret"`, "the secret must be encrypted at rest")
  146. require.Contains(t, raw, "enc:super-secret")
  147. fetched, err := svc.Get(ctx)
  148. require.NoError(t, err)
  149. require.Empty(t, fetched.SecretAccessKey)
  150. require.True(t, svc.SecretConfigured(ctx))
  151. _, enabled := svc.resolve()
  152. require.True(t, enabled)
  153. require.Equal(t, "super-secret", (*built)[0].SecretAccessKey, "the stored secret must be decrypted before use")
  154. // An update that omits the secret keeps the stored one rather than wiping it.
  155. _, err = svc.Update(ctx, ImageStorageSettings{
  156. Enabled: true, Bucket: "my-images",
  157. Endpoint: "https://acct.r2.cloudflarestorage.com", AccessKeyID: "ak",
  158. })
  159. require.NoError(t, err)
  160. svc.resolve()
  161. require.Equal(t, "super-secret", (*built)[1].SecretAccessKey)
  162. }
  163. // Persisting the service's own S3 secret must be refused when the encryption key
  164. // is auto-generated, otherwise the ciphertext cannot be decrypted after a
  165. // restart (#4524). Reusing the backup credentials stays allowed because it does
  166. // not persist a second copy of the secret.
  167. func TestImageStorageSettingsRejectSecretWithEphemeralKey(t *testing.T) {
  168. svc, repo, built := newImageStorageFixtureWithKey(t, config.ImageStorageConfig{}, false)
  169. ctx := context.Background()
  170. _, err := svc.Update(ctx, ImageStorageSettings{
  171. Enabled: true, Bucket: "my-images",
  172. Endpoint: "https://acct.r2.cloudflarestorage.com",
  173. AccessKeyID: "ak", SecretAccessKey: "super-secret",
  174. })
  175. require.ErrorIs(t, err, ErrSecretEncryptionKeyNotConfigured)
  176. raw, _ := repo.GetValue(ctx, settingKeyImageStorageConfig)
  177. require.Empty(t, raw, "nothing must be persisted when the secret is rejected")
  178. require.Empty(t, *built)
  179. // Reusing backup credentials does not persist a secret, so it stays allowed.
  180. seedBackupS3(t, repo, BackupS3Config{
  181. Endpoint: "https://acct.r2.cloudflarestorage.com", Region: "auto",
  182. Bucket: "backup-bucket", AccessKeyID: "ak", SecretAccessKey: "sk", Prefix: "backups/",
  183. })
  184. _, err = svc.Update(ctx, ImageStorageSettings{Enabled: true, ReuseBackupS3: true})
  185. require.NoError(t, err)
  186. }
  187. func TestImageStorageSettingsIncompleteStaysDisabled(t *testing.T) {
  188. svc, _, built := newImageStorageFixture(t, config.ImageStorageConfig{})
  189. ctx := context.Background()
  190. _, err := svc.Update(ctx, ImageStorageSettings{Enabled: true, Bucket: "my-images"})
  191. require.NoError(t, err)
  192. _, enabled := svc.resolve()
  193. require.False(t, enabled, "missing credentials must not enable the feature")
  194. require.Empty(t, *built, "no client is built from an incomplete configuration")
  195. }
  196. // Deployments that already enabled the feature through config.yaml must keep
  197. // working after the setting moves into the database.
  198. func TestImageStorageSettingsFallBackToConfigFile(t *testing.T) {
  199. svc, _, built := newImageStorageFixture(t, config.ImageStorageConfig{
  200. Enabled: true, Endpoint: "https://acct.r2.cloudflarestorage.com", Region: "auto",
  201. Bucket: "yaml-bucket", AccessKeyID: "yaml-ak", SecretAccessKey: "yaml-sk",
  202. Prefix: "images/", MaxDownloadByte: 1024,
  203. })
  204. _, enabled := svc.resolve()
  205. require.True(t, enabled, "config.yaml still enables the feature when nothing is stored yet")
  206. require.Equal(t, "yaml-bucket", (*built)[0].Bucket)
  207. fetched, err := svc.Get(context.Background())
  208. require.NoError(t, err)
  209. require.True(t, fetched.Enabled)
  210. require.Equal(t, "yaml-bucket", fetched.Bucket)
  211. require.Empty(t, fetched.SecretAccessKey)
  212. }