remove distribution from hold, add vulnerability scanning in appview.

1. Removing distribution/distribution from the Hold Service (biggest change)
  The hold service previously used distribution's StorageDriver interface for all blob operations. This replaces it with direct AWS SDK v2 calls through ATCR's own pkg/s3.S3Service:
  - New S3Service methods: Stat(), PutBytes(), Move(), Delete(), WalkBlobs(), ListPrefix() added to pkg/s3/types.go
  - Pull zone fix: Presigned URLs are now generated against the real S3 endpoint, then the host is swapped to the CDN URL post-signing (previously the CDN URL was set as the endpoint, which
  broke SigV4 signatures)
  - All hold subsystems migrated: GC, OCI uploads, XRPC handlers, profile uploads, scan broadcaster, manifest posts — all now use *s3.S3Service instead of storagedriver.StorageDriver
  - Config simplified: Removed configuration.Storage type and buildStorageConfigFromFields(); replaced with a simple S3Params() method
  - Mock expanded: MockS3Client gains an in-memory object store + 5 new methods, replacing duplicate mockStorageDriver implementations in tests (~160 lines deleted from each test file)
2. Vulnerability Scan UI in AppView (new feature)
  Displays scan results from the hold's PDS on the repository page:
  - New lexicon: io/atcr/hold/scan.json with vulnReportBlob field for storing full Grype reports
  - Two new HTMX endpoints: /api/scan-result (badge) and /api/vuln-details (modal with CVE table)
  - New templates: vuln-badge.html (severity count chips) and vuln-details.html (full CVE table with NVD/GHSA links)
  - Repository page: Lazy-loads scan badges per manifest via HTMX
  - Tests: ~590 lines of test coverage for both handlers
3. S3 Diagnostic Tool
  New cmd/s3-test/main.go (418 lines) — tests S3 connectivity with both SDK v1 and v2, including presigned URL generation, pull zone host swapping, and verbose signing debug output.
4. Deployment Tooling
  - New syncServiceUnit() for comparing/updating systemd units on servers
  - Update command now syncs config keys (adds missing keys from template) and service units with daemon-reload
5. DB Migration
  0011_fix_captain_successor_column.yaml — rebuilds hold_captain_records to add the successor column that was missed in a previous migration.
6. Documentation
  - APPVIEW-UI-FUTURE.md rewritten as a status-tracked feature inventory
  - DISTRIBUTION.md renamed to CREDENTIAL_HELPER.md
  - New REMOVING_DISTRIBUTION.md — 480-line analysis of fully removing distribution from the appview side
7. go.mod
  aws-sdk-go v1 moved from indirect to direct (needed by cmd/s3-test).
This commit is contained in:
Evan Jarrett
2026-02-13 15:26:24 -06:00
parent 434a5f1eee
commit de02e1f046
38 changed files with 3134 additions and 962 deletions
+3 -3
View File
@@ -10,8 +10,8 @@ import (
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/s3"
bsky "github.com/bluesky-social/indigo/api/bsky"
"github.com/distribution/distribution/v3/registry/storage/driver"
)
// CreateManifestPost creates a Bluesky post announcing a manifest upload
@@ -19,7 +19,7 @@ import (
// artifactType is "container-image", "helm-chart", or "unknown"
func (p *HoldPDS) CreateManifestPost(
ctx context.Context,
storageDriver driver.StorageDriver,
s3svc *s3.S3Service,
repository, tag, userHandle, userDID, digest string,
totalSize int64,
platforms []string,
@@ -50,7 +50,7 @@ func (p *HoldPDS) CreateManifestPost(
slog.Warn("Failed to fetch OG image, posting without embed", "error", err)
} else {
// Upload OG image as blob
thumbBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, ogImageData, "image/png")
thumbBlob, err := uploadBlobToStorage(ctx, s3svc, p.did, ogImageData, "image/png")
if err != nil {
slog.Warn("Failed to upload OG image blob", "error", err)
} else {
+9 -29
View File
@@ -1,7 +1,6 @@
package pds
import (
"bytes"
"context"
"crypto/sha256"
"fmt"
@@ -11,9 +10,9 @@ import (
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/s3"
bsky "github.com/bluesky-social/indigo/api/bsky"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/ipfs/go-cid"
"github.com/multiformats/go-multihash"
)
@@ -68,9 +67,9 @@ func downloadImage(ctx context.Context, url string) ([]byte, string, error) {
return data, contentType, nil
}
// uploadBlobToStorage uploads a blob to the hold's storage and returns a blob reference
// This stores the blob at the ATProto path for the hold's DID
func uploadBlobToStorage(ctx context.Context, storageDriver driver.StorageDriver, did string, data []byte, mimeType string) (*lexutil.LexBlob, error) {
// uploadBlobToStorage uploads a blob to the hold's S3 storage and returns a blob reference.
// This stores the blob at the ATProto path for the hold's DID.
func uploadBlobToStorage(ctx context.Context, s3svc *s3.S3Service, did string, data []byte, mimeType string) (*lexutil.LexBlob, error) {
if len(data) == 0 {
return nil, fmt.Errorf("empty blob data")
}
@@ -90,33 +89,14 @@ func uploadBlobToStorage(ctx context.Context, storageDriver driver.StorageDriver
// ATProto uses CIDv1 with raw codec for blobs
blobCID := cid.NewCidV1(0x55, mh)
// Store blob via distribution driver at ATProto path
// Store blob via S3 at ATProto path
path := atprotoBlobPath(did, blobCID.String())
// Write blob to storage using distribution driver
writer, err := storageDriver.Writer(ctx, path, false)
if err != nil {
return nil, fmt.Errorf("failed to create writer: %w", err)
}
// Write data
n, err := io.Copy(writer, bytes.NewReader(data))
if err != nil {
writer.Cancel(ctx)
return nil, fmt.Errorf("failed to write blob: %w", err)
}
// Commit the write
if err := writer.Commit(ctx); err != nil {
return nil, fmt.Errorf("failed to commit blob: %w", err)
}
if n != size {
return nil, fmt.Errorf("size mismatch: wrote %d bytes, expected %d", n, size)
if err := s3svc.PutBytes(ctx, path, data, mimeType); err != nil {
return nil, fmt.Errorf("failed to put blob: %w", err)
}
// Create blob reference in the format expected by bsky.ActorProfile
// LexLink is a type alias for cid.Cid
lexLink := lexutil.LexLink(blobCID)
blob := &lexutil.LexBlob{
Ref: lexLink,
@@ -129,7 +109,7 @@ func uploadBlobToStorage(ctx context.Context, storageDriver driver.StorageDriver
// CreateProfileRecord creates the app.bsky.actor.profile record for the hold
// This will FAIL if the profile record already exists.
func (p *HoldPDS) CreateProfileRecord(ctx context.Context, storageDriver driver.StorageDriver, displayName, description, avatarURL string) (cid.Cid, error) {
func (p *HoldPDS) CreateProfileRecord(ctx context.Context, s3svc *s3.S3Service, displayName, description, avatarURL string) (cid.Cid, error) {
// Create profile struct
profile := &bsky.ActorProfile{
DisplayName: &displayName,
@@ -147,7 +127,7 @@ func (p *HoldPDS) CreateProfileRecord(ctx context.Context, storageDriver driver.
slog.Debug("Uploading avatar blob",
"size", len(imageData),
"mimeType", mimeType)
avatarBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, imageData, mimeType)
avatarBlob, err := uploadBlobToStorage(ctx, s3svc, p.did, imageData, mimeType)
if err != nil {
return cid.Undef, fmt.Errorf("failed to upload avatar blob: %w", err)
}
+21 -8
View File
@@ -13,8 +13,8 @@ import (
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/s3"
lexutil "github.com/bluesky-social/indigo/lex/util"
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/gorilla/websocket"
)
@@ -28,7 +28,7 @@ type ScanBroadcaster struct {
db *sql.DB
holdDID string
holdEndpoint string
driver storagedriver.StorageDriver
s3 *s3.S3Service
pds *HoldPDS
ackTimeout time.Duration
secret string // Shared secret for scanner authentication
@@ -80,7 +80,7 @@ type VulnerabilitySummary struct {
// NewScanBroadcaster creates a new scan job broadcaster
// dbPath should point to a SQLite database file (e.g., "/path/to/pds/db.sqlite3")
func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, driver storagedriver.StorageDriver, holdPDS *HoldPDS) (*ScanBroadcaster, error) {
func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, s3svc *s3.S3Service, holdPDS *HoldPDS) (*ScanBroadcaster, error) {
dsn := dbPath
if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") {
dsn = "file:" + dbPath
@@ -99,7 +99,7 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, driver sto
db: db,
holdDID: holdDID,
holdEndpoint: holdEndpoint,
driver: driver,
s3: s3svc,
pds: holdPDS,
ackTimeout: 5 * time.Minute,
secret: secret,
@@ -119,13 +119,13 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, driver sto
// NewScanBroadcasterWithDB creates a scan job broadcaster using an existing *sql.DB connection.
// The caller is responsible for the DB lifecycle.
func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret string, db *sql.DB, driver storagedriver.StorageDriver, holdPDS *HoldPDS) (*ScanBroadcaster, error) {
func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret string, db *sql.DB, s3svc *s3.S3Service, holdPDS *HoldPDS) (*ScanBroadcaster, error) {
sb := &ScanBroadcaster{
subscribers: make([]*ScanSubscriber, 0),
db: db,
holdDID: holdDID,
holdEndpoint: holdEndpoint,
driver: driver,
s3: s3svc,
pds: holdPDS,
ackTimeout: 5 * time.Minute,
secret: secret,
@@ -424,7 +424,7 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
// Upload SBOM as a blob to the hold's PDS blob storage (like manifest blobs)
var sbomBlob *lexutil.LexBlob
if msg.SBOM != "" {
blob, err := uploadBlobToStorage(ctx, sb.driver, sb.holdDID, []byte(msg.SBOM), "application/spdx+json")
blob, err := uploadBlobToStorage(ctx, sb.s3, sb.holdDID, []byte(msg.SBOM), "application/spdx+json")
if err != nil {
slog.Error("Failed to upload SBOM blob to PDS storage",
"seq", msg.Seq,
@@ -434,11 +434,24 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
}
}
// Upload vulnerability report as a blob (full Grype JSON with CVE details)
var vulnReportBlob *lexutil.LexBlob
if msg.VulnReport != "" {
blob, err := uploadBlobToStorage(ctx, sb.s3, sb.holdDID, []byte(msg.VulnReport), "application/vnd.atcr.vulnerabilities+json")
if err != nil {
slog.Error("Failed to upload VulnReport blob to PDS storage",
"seq", msg.Seq,
"error", err)
} else {
vulnReportBlob = blob
}
}
// Store scan result as a record in the hold's embedded PDS
if msg.Summary != nil {
scanRecord := atproto.NewScanRecord(
manifestDigest, repository, userDID,
sbomBlob,
sbomBlob, vulnReportBlob,
msg.Summary.Critical, msg.Summary.High, msg.Summary.Medium, msg.Summary.Low, msg.Summary.Total,
"atcr-scanner-v1.0.0",
)
+5 -5
View File
@@ -13,11 +13,11 @@ import (
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
holddb "atcr.io/pkg/hold/db"
"atcr.io/pkg/s3"
"github.com/bluesky-social/indigo/atproto/atcrypto"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/bluesky-social/indigo/models"
"github.com/bluesky-social/indigo/repo"
"github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/ipfs/go-cid"
)
@@ -231,7 +231,7 @@ func (p *HoldPDS) GetRecordBytes(ctx context.Context, recordPath string) (cid.Ci
}
// Bootstrap initializes the hold with the captain record, owner as first crew member, and profile
func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error {
func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error {
if ownerDID == "" {
return nil
}
@@ -317,15 +317,15 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri
// Create Bluesky profile record (idempotent - check if exists first)
// This runs even if captain exists (for existing holds being upgraded)
// Skip if no storage driver (e.g., in tests)
if storageDriver != nil {
// Skip if no S3 service (e.g., in tests)
if s3svc != nil {
_, _, err = p.GetProfileRecord(ctx)
if err != nil {
// Bluesky profile doesn't exist, create it
displayName := "Cargo Hold"
description := "ahoy from the cargo hold"
_, err = p.CreateProfileRecord(ctx, storageDriver, displayName, description, avatarURL)
_, err = p.CreateProfileRecord(ctx, s3svc, displayName, description, avatarURL)
if err != nil {
return fmt.Errorf("failed to create bluesky profile record: %w", err)
}
+2 -2
View File
@@ -55,7 +55,7 @@ func TestStatusPost(t *testing.T) {
}
// Create handler for XRPC endpoints
handler := NewXRPCHandler(holdPDS, s3.S3Service{}, nil, nil, &mockPDSClient{}, nil)
handler := NewXRPCHandler(holdPDS, s3.S3Service{}, nil, &mockPDSClient{}, nil)
// Helper function to list posts via XRPC
listPosts := func() ([]map[string]any, error) {
@@ -283,7 +283,7 @@ func TestMain(m *testing.M) {
}
// Create shared handler
sharedHandler = NewXRPCHandler(sharedPDS, s3.S3Service{}, nil, nil, &mockPDSClient{}, nil)
sharedHandler = NewXRPCHandler(sharedPDS, s3.S3Service{}, nil, &mockPDSClient{}, nil)
// Run tests
code := m.Run()
+10 -34
View File
@@ -12,7 +12,6 @@ import (
"github.com/bluesky-social/indigo/api/bsky"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/bluesky-social/indigo/repo"
"github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/gorilla/websocket"
@@ -46,7 +45,6 @@ const (
type XRPCHandler struct {
pds *HoldPDS
s3Service s3.S3Service
storageDriver driver.StorageDriver
broadcaster *EventBroadcaster
scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners
httpClient HTTPClient // For testing - allows injecting mock HTTP client
@@ -68,14 +66,13 @@ type PartUploadInfo struct {
}
// NewXRPCHandler creates a new XRPC handler
func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, storageDriver driver.StorageDriver, broadcaster *EventBroadcaster, httpClient HTTPClient, quotaMgr *quota.Manager) *XRPCHandler {
func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, broadcaster *EventBroadcaster, httpClient HTTPClient, quotaMgr *quota.Manager) *XRPCHandler {
return &XRPCHandler{
pds: pds,
s3Service: s3Service,
storageDriver: storageDriver,
broadcaster: broadcaster,
httpClient: httpClient,
quotaMgr: quotaMgr,
pds: pds,
s3Service: s3Service,
broadcaster: broadcaster,
httpClient: httpClient,
quotaMgr: quotaMgr,
}
}
@@ -1052,32 +1049,11 @@ func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
// ATProto uses CIDv1 with raw codec for blobs
blobCID := cid.NewCidV1(0x55, mh)
// Store blob via distribution driver at ATProto path
// Store blob via S3 at ATProto path
path := atprotoBlobPath(did, blobCID.String())
// Write blob to storage using distribution driver
writer, err := h.storageDriver.Writer(r.Context(), path, false)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create writer: %v", err), http.StatusInternalServerError)
return
}
// Write data
n, err := io.Copy(writer, bytes.NewReader(blobData))
if err != nil {
writer.Cancel(r.Context())
http.Error(w, fmt.Sprintf("failed to write blob: %v", err), http.StatusInternalServerError)
return
}
// Commit the write
if err := writer.Commit(r.Context()); err != nil {
http.Error(w, fmt.Sprintf("failed to commit blob: %v", err), http.StatusInternalServerError)
return
}
if n != size {
http.Error(w, fmt.Sprintf("size mismatch: wrote %d bytes, expected %d", n, size), http.StatusInternalServerError)
if err := h.s3Service.PutBytes(r.Context(), path, blobData, "application/octet-stream"); err != nil {
http.Error(w, fmt.Sprintf("failed to put blob: %v", err), http.StatusInternalServerError)
return
}
@@ -1259,7 +1235,7 @@ func (h *XRPCHandler) HandleListBlobs(w http.ResponseWriter, r *http.Request) {
safeDID := strings.ReplaceAll(did, ":", "-")
blobsPath := fmt.Sprintf("/repos/%s/blobs", safeDID)
entries, err := h.storageDriver.List(r.Context(), blobsPath)
entries, err := h.s3Service.ListPrefix(r.Context(), blobsPath)
if err != nil {
// Path doesn't exist = no blobs, return empty list
render.JSON(w, r, map[string]any{"cids": []string{}})
+24 -62
View File
@@ -18,8 +18,6 @@ import (
"atcr.io/pkg/s3"
indigoAtproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/events"
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
_ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
"github.com/ipfs/go-cid"
@@ -76,7 +74,7 @@ func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) {
mockS3 := s3.S3Service{}
// Create XRPC handler with mock HTTP client
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil)
handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil)
return handler, ctx
}
@@ -143,7 +141,7 @@ func setupTestXRPCHandlerWithIndex(t *testing.T) (*XRPCHandler, context.Context)
mockS3 := s3.S3Service{}
// Create XRPC handler with mock HTTP client
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil)
handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil)
return handler, ctx
}
@@ -753,7 +751,7 @@ func TestHandleListRecords_EmptyCollection(t *testing.T) {
pds, ctx := setupTestPDS(t) // Don't bootstrap - no records created yet
mockClient := &mockPDSClient{}
mockS3 := s3.S3Service{}
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil)
handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil)
// Initialize repo manually (setupTestPDS doesn't call Bootstrap, so no crew members)
err := pds.repomgr.InitNewActor(ctx, pds.uid, "", pds.did, "", "", "")
@@ -1231,7 +1229,7 @@ func TestHandleListRepos_EmptyRepo(t *testing.T) {
pds, ctx := setupTestPDS(t) // Don't bootstrap
mockClient := &mockPDSClient{}
mockS3 := s3.S3Service{}
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil)
handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil)
// setupTestPDS creates the PDS/database but doesn't initialize the repo
// Check if implementation returns repos before initialization
@@ -1317,7 +1315,7 @@ func TestHandleGetRepoStatus_EmptyRepo(t *testing.T) {
pds, ctx := setupTestPDS(t) // Don't bootstrap
mockClient := &mockPDSClient{}
mockS3 := s3.S3Service{}
handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil)
handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil)
holdDID := "did:web:hold.example.com"
// Initialize repo but don't add any records
@@ -1960,27 +1958,6 @@ func TestHandleAtprotoDID(t *testing.T) {
// Mock S3 Service for testing blob endpoints
// mockS3Service is a simple mock that tracks calls and returns test URLs
type mockS3Service struct {
// Track calls
downloadCalls []string // Track digests requested for download
}
func newMockS3Service() *mockS3Service {
return &mockS3Service{
downloadCalls: []string{},
}
}
// toS3Service converts the mock to an s3.S3Service
// Returns empty s3.S3Service since we're not testing S3 presigned URLs in these tests
func (m *mockS3Service) toS3Service() s3.S3Service {
return s3.S3Service{
Client: nil, // Not testing presigned URLs
Bucket: "",
PathPrefix: "",
}
}
// setupTestXRPCHandlerWithMockS3 creates handler with MockS3Client for testing presigned URLs
func setupTestXRPCHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client, context.Context) {
t.Helper()
@@ -2029,27 +2006,17 @@ func setupTestXRPCHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Clien
PathPrefix: "test-prefix",
}
// Create filesystem storage driver for tests
storageDir := filepath.Join(tmpDir, "storage")
params := map[string]any{
"rootdirectory": storageDir,
}
driver, err := factory.Create(ctx, "filesystem", params)
if err != nil {
t.Fatalf("Failed to create storage driver: %v", err)
}
// Create mock PDS client for DPoP validation
mockClient := &mockPDSClient{}
// Create XRPC handler with mock S3 client and real filesystem driver
handler := NewXRPCHandler(pds, s3Service, driver, nil, mockClient, nil)
// Create XRPC handler with mock S3 client
handler := NewXRPCHandler(pds, s3Service, nil, mockClient, nil)
return handler, mockS3Client, ctx
}
// setupTestXRPCHandlerWithBlobs creates handler with mock s3 service and real filesystem driver
func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockS3Service, context.Context) {
// setupTestXRPCHandlerWithBlobs creates handler with MockS3Client for upload/list testing
func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *s3.MockS3Client, context.Context) {
t.Helper()
ctx := context.Background()
@@ -2088,26 +2055,21 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockS3Service,
t.Fatalf("Failed to bootstrap PDS: %v", err)
}
// Create mock s3 service that returns test URLs
mockS3Svc := newMockS3Service()
// Create filesystem storage driver for tests
storageDir := filepath.Join(tmpDir, "storage")
params := map[string]any{
"rootdirectory": storageDir,
}
driver, err := factory.Create(ctx, "filesystem", params)
if err != nil {
t.Fatalf("Failed to create storage driver: %v", err)
// Create MockS3Client for blob upload/list
mockS3Client := s3.NewMockS3Client("https://mock-s3.example.com")
s3Service := s3.S3Service{
Client: mockS3Client,
Bucket: "test-bucket",
PathPrefix: "",
}
// Create mock PDS client for DPoP validation
mockClient := &mockPDSClient{}
// Create XRPC handler with mock s3 service and real filesystem driver
handler := NewXRPCHandler(pds, mockS3Svc.toS3Service(), driver, nil, mockClient, nil)
// Create XRPC handler
handler := NewXRPCHandler(pds, s3Service, nil, mockClient, nil)
return handler, mockS3Svc, ctx
return handler, mockS3Client, ctx
}
// Tests for HandleUploadBlob
@@ -2391,9 +2353,9 @@ func TestHandleGetBlob(t *testing.T) {
t.Error("Expected Location header in 307 redirect")
}
// Should be XRPC proxy URL since we don't have S3 client
if !strings.Contains(location, "/xrpc/com.atproto.sync.getBlob") {
t.Errorf("Expected XRPC proxy URL, got: %s", location)
// Should be a presigned URL from the mock S3 client
if !strings.Contains(location, "mock-s3.example.com") {
t.Errorf("Expected presigned S3 URL, got: %s", location)
}
}
@@ -2457,9 +2419,9 @@ func TestHandleGetBlob_HeadMethod(t *testing.T) {
t.Error("Expected Location header in 307 redirect")
}
// Should be XRPC proxy URL since we don't have S3 client
if !strings.Contains(location, "/xrpc/com.atproto.sync.getBlob") {
t.Errorf("Expected XRPC proxy URL, got: %s", location)
// Should be a presigned URL from the mock S3 client
if !strings.Contains(location, "mock-s3.example.com") {
t.Errorf("Expected presigned S3 URL, got: %s", location)
}
}