| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254 |
- //go:build unit
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "strings"
- "sync"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/stretchr/testify/require"
- )
- type stubSettingRepo struct {
- mu sync.Mutex
- values map[string]string
- }
- func newStubSettingRepo() *stubSettingRepo {
- return &stubSettingRepo{values: map[string]string{}}
- }
- func (r *stubSettingRepo) Get(context.Context, string) (*Setting, error) { return nil, nil }
- func (r *stubSettingRepo) GetValue(_ context.Context, key string) (string, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- return r.values[key], nil
- }
- func (r *stubSettingRepo) Set(_ context.Context, key, value string) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.values[key] = value
- return nil
- }
- func (r *stubSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
- return map[string]string{}, nil
- }
- func (r *stubSettingRepo) SetMultiple(context.Context, map[string]string) error { return nil }
- func (r *stubSettingRepo) GetAll(context.Context) (map[string]string, error) {
- return map[string]string{}, nil
- }
- func (r *stubSettingRepo) Delete(context.Context, string) error { return nil }
- // reversibleEncryptor stands in for AES: prefixed so a test can tell ciphertext
- // from plaintext, and so decrypting a plaintext value fails like the real one.
- type reversibleEncryptor struct{}
- func (reversibleEncryptor) Encrypt(plaintext string) (string, error) {
- return "enc:" + plaintext, nil
- }
- func (reversibleEncryptor) Decrypt(ciphertext string) (string, error) {
- rest, ok := strings.CutPrefix(ciphertext, "enc:")
- if !ok {
- return "", errors.New("not encrypted")
- }
- return rest, nil
- }
- type recordingStorage struct{ saved []string }
- func (s *recordingStorage) Save(_ context.Context, key, _ string, _ []byte) (string, error) {
- s.saved = append(s.saved, key)
- return "https://cdn.example.com/" + key, nil
- }
- func newImageStorageFixture(t *testing.T, fallback config.ImageStorageConfig) (*ImageStorageSettingService, *stubSettingRepo, *[]config.ImageStorageConfig) {
- return newImageStorageFixtureWithKey(t, fallback, true)
- }
- func newImageStorageFixtureWithKey(t *testing.T, fallback config.ImageStorageConfig, encryptionKeyConfigured bool) (*ImageStorageSettingService, *stubSettingRepo, *[]config.ImageStorageConfig) {
- t.Helper()
- repo := newStubSettingRepo()
- encryptor := reversibleEncryptor{}
- backup := NewBackupService(repo, &config.Config{
- Totp: config.TotpConfig{EncryptionKeyConfigured: encryptionKeyConfigured},
- }, encryptor, nil, nil)
- var built []config.ImageStorageConfig
- factory := func(_ context.Context, cfg *config.ImageStorageConfig) (ImageStorage, error) {
- built = append(built, *cfg)
- return &recordingStorage{}, nil
- }
- return NewImageStorageSettingService(repo, encryptor, backup, factory, fallback), repo, &built
- }
- func seedBackupS3(t *testing.T, repo *stubSettingRepo, cfg BackupS3Config) {
- t.Helper()
- cfg.SecretAccessKey = "enc:" + cfg.SecretAccessKey
- data, err := json.Marshal(cfg)
- require.NoError(t, err)
- require.NoError(t, repo.Set(context.Background(), settingKeyBackupS3Config, string(data)))
- }
- // The admin switch must take effect without a restart: that is the entire point
- // of moving image_storage out of config.yaml (#4542).
- func TestImageStorageSettingsToggleTakesEffectWithoutRestart(t *testing.T) {
- svc, repo, built := newImageStorageFixture(t, config.ImageStorageConfig{})
- ctx := context.Background()
- seedBackupS3(t, repo, BackupS3Config{
- Endpoint: "https://acct.r2.cloudflarestorage.com", Region: "auto",
- Bucket: "backup-bucket", AccessKeyID: "ak", SecretAccessKey: "sk",
- Prefix: "backups/",
- })
- uploader, enabled := svc.resolve()
- require.False(t, enabled, "disabled until an admin turns it on")
- require.Nil(t, uploader)
- _, err := svc.Update(ctx, ImageStorageSettings{Enabled: true, ReuseBackupS3: true})
- require.NoError(t, err)
- uploader, enabled = svc.resolve()
- require.True(t, enabled, "saving the setting must enable the feature immediately")
- require.NotNil(t, uploader)
- _, err = svc.Update(ctx, ImageStorageSettings{Enabled: false, ReuseBackupS3: true})
- require.NoError(t, err)
- _, enabled = svc.resolve()
- require.False(t, enabled, "turning it back off must also apply immediately")
- require.Len(t, *built, 1, "the S3 client is built only when the feature is on")
- }
- func TestImageStorageSettingsReuseBackupCredentials(t *testing.T) {
- svc, repo, built := newImageStorageFixture(t, config.ImageStorageConfig{})
- ctx := context.Background()
- seedBackupS3(t, repo, BackupS3Config{
- Endpoint: "https://acct.r2.cloudflarestorage.com", Region: "wnam",
- Bucket: "backup-bucket", AccessKeyID: "backup-ak", SecretAccessKey: "backup-sk",
- Prefix: "backups/", ForcePathStyle: true,
- })
- _, err := svc.Update(ctx, ImageStorageSettings{Enabled: true, ReuseBackupS3: true, Prefix: "images"})
- require.NoError(t, err)
- _, enabled := svc.resolve()
- require.True(t, enabled)
- require.Len(t, *built, 1)
- got := (*built)[0]
- require.Equal(t, "https://acct.r2.cloudflarestorage.com", got.Endpoint)
- require.Equal(t, "wnam", got.Region)
- require.Equal(t, "backup-ak", got.AccessKeyID)
- require.Equal(t, "backup-sk", got.SecretAccessKey, "the backup secret must be decrypted before use")
- require.True(t, got.ForcePathStyle)
- require.Equal(t, "backup-bucket", got.Bucket, "an empty bucket falls back to the backup bucket")
- require.Equal(t, "images/", got.Prefix, "images stay under their own prefix so they never collide with backups/")
- // Reusing must not duplicate the secret into a second row.
- raw, err := repo.GetValue(ctx, settingKeyImageStorageConfig)
- require.NoError(t, err)
- require.NotContains(t, raw, "backup-sk")
- require.NotContains(t, raw, "enc:")
- }
- func TestImageStorageSettingsOwnCredentialsAreEncryptedAndMasked(t *testing.T) {
- svc, repo, built := newImageStorageFixture(t, config.ImageStorageConfig{})
- ctx := context.Background()
- saved, err := svc.Update(ctx, ImageStorageSettings{
- Enabled: true, Bucket: "my-images",
- Endpoint: "https://acct.r2.cloudflarestorage.com",
- AccessKeyID: "ak", SecretAccessKey: "super-secret",
- })
- require.NoError(t, err)
- require.Empty(t, saved.SecretAccessKey, "the response must never echo the secret back")
- raw, err := repo.GetValue(ctx, settingKeyImageStorageConfig)
- require.NoError(t, err)
- require.NotContains(t, raw, `"secret_access_key":"super-secret"`, "the secret must be encrypted at rest")
- require.Contains(t, raw, "enc:super-secret")
- fetched, err := svc.Get(ctx)
- require.NoError(t, err)
- require.Empty(t, fetched.SecretAccessKey)
- require.True(t, svc.SecretConfigured(ctx))
- _, enabled := svc.resolve()
- require.True(t, enabled)
- require.Equal(t, "super-secret", (*built)[0].SecretAccessKey, "the stored secret must be decrypted before use")
- // An update that omits the secret keeps the stored one rather than wiping it.
- _, err = svc.Update(ctx, ImageStorageSettings{
- Enabled: true, Bucket: "my-images",
- Endpoint: "https://acct.r2.cloudflarestorage.com", AccessKeyID: "ak",
- })
- require.NoError(t, err)
- svc.resolve()
- require.Equal(t, "super-secret", (*built)[1].SecretAccessKey)
- }
- // Persisting the service's own S3 secret must be refused when the encryption key
- // is auto-generated, otherwise the ciphertext cannot be decrypted after a
- // restart (#4524). Reusing the backup credentials stays allowed because it does
- // not persist a second copy of the secret.
- func TestImageStorageSettingsRejectSecretWithEphemeralKey(t *testing.T) {
- svc, repo, built := newImageStorageFixtureWithKey(t, config.ImageStorageConfig{}, false)
- ctx := context.Background()
- _, err := svc.Update(ctx, ImageStorageSettings{
- Enabled: true, Bucket: "my-images",
- Endpoint: "https://acct.r2.cloudflarestorage.com",
- AccessKeyID: "ak", SecretAccessKey: "super-secret",
- })
- require.ErrorIs(t, err, ErrSecretEncryptionKeyNotConfigured)
- raw, _ := repo.GetValue(ctx, settingKeyImageStorageConfig)
- require.Empty(t, raw, "nothing must be persisted when the secret is rejected")
- require.Empty(t, *built)
- // Reusing backup credentials does not persist a secret, so it stays allowed.
- seedBackupS3(t, repo, BackupS3Config{
- Endpoint: "https://acct.r2.cloudflarestorage.com", Region: "auto",
- Bucket: "backup-bucket", AccessKeyID: "ak", SecretAccessKey: "sk", Prefix: "backups/",
- })
- _, err = svc.Update(ctx, ImageStorageSettings{Enabled: true, ReuseBackupS3: true})
- require.NoError(t, err)
- }
- func TestImageStorageSettingsIncompleteStaysDisabled(t *testing.T) {
- svc, _, built := newImageStorageFixture(t, config.ImageStorageConfig{})
- ctx := context.Background()
- _, err := svc.Update(ctx, ImageStorageSettings{Enabled: true, Bucket: "my-images"})
- require.NoError(t, err)
- _, enabled := svc.resolve()
- require.False(t, enabled, "missing credentials must not enable the feature")
- require.Empty(t, *built, "no client is built from an incomplete configuration")
- }
- // Deployments that already enabled the feature through config.yaml must keep
- // working after the setting moves into the database.
- func TestImageStorageSettingsFallBackToConfigFile(t *testing.T) {
- svc, _, built := newImageStorageFixture(t, config.ImageStorageConfig{
- Enabled: true, Endpoint: "https://acct.r2.cloudflarestorage.com", Region: "auto",
- Bucket: "yaml-bucket", AccessKeyID: "yaml-ak", SecretAccessKey: "yaml-sk",
- Prefix: "images/", MaxDownloadByte: 1024,
- })
- _, enabled := svc.resolve()
- require.True(t, enabled, "config.yaml still enables the feature when nothing is stored yet")
- require.Equal(t, "yaml-bucket", (*built)[0].Bucket)
- fetched, err := svc.Get(context.Background())
- require.NoError(t, err)
- require.True(t, fetched.Enabled)
- require.Equal(t, "yaml-bucket", fetched.Bucket)
- require.Empty(t, fetched.SecretAccessKey)
- }
|