feat(ops): add object storage management and site-mapping CSV import/export
Ship the Ops backstage 对象存储 page (browse / upload / move / delete / folder ops) backed by a new object-storage service + handler, with the shared storage client extended with List/Stat/Copy/Usage/DownloadURL so both MinIO and Aliyun providers cover the new surface. Add ForcePathStyle to the object-storage config and treat r2/s3/custom_s3 as external providers so Cloudflare R2 and other S3-compatibles can share the MinIO client path without spinning up the bundled MinIO. Also add CSV import/export + template download to the site-domain mappings page, raise gin MaxMultipartMemory to 8 MiB for the new multipart endpoints, register Ops swagger routes, and extend the descriptions-parity test to cover the ops router. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeOSSCommonPrefixesDerivesNextLevelFolders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
objects := []objectstorage.ObjectInfo{
|
||||
{Key: "reports/2026/report.txt"},
|
||||
{Key: "reports/2026/monthly/january.txt"},
|
||||
{Key: "reports/2025/summary.txt"},
|
||||
{Key: "reports/readme.txt"},
|
||||
{Key: "images/logo.png"},
|
||||
}
|
||||
|
||||
require.Equal(t,
|
||||
[]string{"reports/2026/", "reports/2025/"},
|
||||
normalizeOSSCommonPrefixes("reports/", nil, objects, false),
|
||||
)
|
||||
require.Equal(t,
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}},
|
||||
normalizeOSSListedObjects("reports/", objects, false),
|
||||
)
|
||||
}
|
||||
|
||||
func TestNormalizeOSSListedObjectsFiltersFolderPlaceholders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
objects := []objectstorage.ObjectInfo{
|
||||
{Key: "reports/"},
|
||||
{Key: "reports/readme.txt"},
|
||||
{Key: "reports/2026/"},
|
||||
{Key: "reports/2026/report.txt"},
|
||||
}
|
||||
|
||||
require.Equal(t,
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}},
|
||||
normalizeOSSListedObjects("reports/", objects, false),
|
||||
)
|
||||
require.Equal(t,
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}, {Key: "reports/2026/report.txt"}},
|
||||
normalizeOSSListedObjects("reports/", objects, true),
|
||||
)
|
||||
}
|
||||
|
||||
func TestNormalizeOSSCommonPrefixesKeepsProviderPrefixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := normalizeOSSCommonPrefixes(
|
||||
"reports/",
|
||||
[]string{"reports/2026/", "reports/2025/"},
|
||||
[]objectstorage.ObjectInfo{
|
||||
{Key: "reports/2026/report.txt"},
|
||||
{Key: "reports/2025/summary.txt"},
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
||||
require.Equal(t, []string{"reports/2026/", "reports/2025/"}, got)
|
||||
}
|
||||
|
||||
func TestNormalizeOSSCommonPrefixesOmitsFoldersInRecursiveMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := normalizeOSSCommonPrefixes(
|
||||
"reports/",
|
||||
[]string{"reports/2026/"},
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/2026/report.txt"}},
|
||||
true,
|
||||
)
|
||||
|
||||
require.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestShouldFallbackOSSDirectoryList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/"},
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
require.False(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/", Recursive: true},
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
require.False(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/"},
|
||||
"release",
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
require.False(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/"},
|
||||
"",
|
||||
nil,
|
||||
[]string{"desktop-client/releases/"},
|
||||
))
|
||||
}
|
||||
|
||||
func TestCreateFolderWritesPlaceholder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
result, err := svc.CreateFolder(context.Background(), nil, ObjectStorageFolderCreateInput{Prefix: "reports/2026"})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "reports/2026/", result.Prefix)
|
||||
require.Contains(t, storage.objects, "reports/2026/")
|
||||
require.Equal(t, []byte(nil), storage.objects["reports/2026/"])
|
||||
require.Equal(t, "application/octet-stream", storage.contentTypes["reports/2026/"])
|
||||
}
|
||||
|
||||
func TestCreateFolderRejectsExistingPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
storage.objects["reports/2026/report.txt"] = []byte("content")
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
_, err := svc.CreateFolder(context.Background(), nil, ObjectStorageFolderCreateInput{Prefix: "reports/2026/"})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "object_storage_folder_exists")
|
||||
}
|
||||
|
||||
func TestDeleteFolderDeletesAllObjectsUnderPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
storage.objects["reports/2026/"] = nil
|
||||
storage.objects["reports/2026/a.txt"] = []byte("a")
|
||||
storage.objects["reports/2026/nested/b.txt"] = []byte("b")
|
||||
storage.objects["reports/2025/a.txt"] = []byte("old")
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
result, err := svc.DeleteFolder(context.Background(), nil, "reports/2026/")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "reports/2026/", result.Prefix)
|
||||
require.Equal(t, 3, result.DeletedCount)
|
||||
require.NotContains(t, storage.objects, "reports/2026/")
|
||||
require.NotContains(t, storage.objects, "reports/2026/a.txt")
|
||||
require.NotContains(t, storage.objects, "reports/2026/nested/b.txt")
|
||||
require.Contains(t, storage.objects, "reports/2025/a.txt")
|
||||
}
|
||||
|
||||
func TestGetReturnsPreviewURLAndOmitsNonPublicURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
storage.objects["reports/secret.png"] = []byte("image")
|
||||
storage.publicReadable = false
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
result, err := svc.Get(context.Background(), "reports/secret.png")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, result.PublicURL)
|
||||
require.NotNil(t, result.PreviewURL)
|
||||
require.Equal(t, "https://example.test/download/reports/secret.png", *result.PreviewURL)
|
||||
}
|
||||
|
||||
type fakeObjectStorage struct {
|
||||
objects map[string][]byte
|
||||
contentTypes map[string]string
|
||||
publicReadable bool
|
||||
}
|
||||
|
||||
func newFakeObjectStorage() *fakeObjectStorage {
|
||||
return &fakeObjectStorage{
|
||||
objects: make(map[string][]byte),
|
||||
contentTypes: make(map[string]string),
|
||||
publicReadable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) PutBytes(_ context.Context, objectKey string, content []byte, contentType string) error {
|
||||
s.objects[objectKey] = content
|
||||
s.contentTypes[objectKey] = contentType
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
|
||||
content, ok := s.objects[objectKey]
|
||||
if !ok {
|
||||
return nil, objectstorage.ErrObjectNotFound
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Exists(_ context.Context, objectKey string) (bool, error) {
|
||||
_, ok := s.objects[objectKey]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Delete(_ context.Context, objectKey string) error {
|
||||
delete(s.objects, objectKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) PublicURL(objectKey string) (string, error) {
|
||||
return "https://example.test/" + objectKey, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) PublicReadableURL(objectKey string) (string, error) {
|
||||
if !s.publicReadable {
|
||||
return "", objectstorage.ErrObjectNotPublic
|
||||
}
|
||||
return "https://example.test/" + objectKey, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) DownloadURL(objectKey string) (string, error) {
|
||||
return "https://example.test/download/" + objectKey, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) List(_ context.Context, in objectstorage.ListInput) (*objectstorage.ListResult, error) {
|
||||
if in.Limit == 0 {
|
||||
return nil, errors.New("test list limit must be set")
|
||||
}
|
||||
result := &objectstorage.ListResult{}
|
||||
for key, content := range s.objects {
|
||||
if strings.HasPrefix(key, in.Prefix) {
|
||||
result.Objects = append(result.Objects, objectstorage.ObjectInfo{
|
||||
Key: key,
|
||||
Size: int64(len(content)),
|
||||
LastModified: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Stat(_ context.Context, objectKey string) (*objectstorage.ObjectInfo, error) {
|
||||
content, ok := s.objects[objectKey]
|
||||
if !ok {
|
||||
return nil, objectstorage.ErrObjectNotFound
|
||||
}
|
||||
return &objectstorage.ObjectInfo{Key: objectKey, Size: int64(len(content)), LastModified: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Copy(_ context.Context, sourceKey, destinationKey string) error {
|
||||
content, ok := s.objects[sourceKey]
|
||||
if !ok {
|
||||
return objectstorage.ErrObjectNotFound
|
||||
}
|
||||
s.objects[destinationKey] = append([]byte(nil), content...)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user