package storage import ( "bytes" "context" "io" "os" "testing" ) // ── Unit Tests (always run) ────────────── func TestS3_NewS3_Validation(t *testing.T) { // Missing bucket _, err := NewS3(S3Config{ AccessKey: "test", SecretKey: "test", }) if err == nil { t.Error("expected error for missing bucket") } // Missing credentials _, err = NewS3(S3Config{ Bucket: "test-bucket", }) if err == nil { t.Error("expected error for missing credentials") } // Missing access key only _, err = NewS3(S3Config{ Bucket: "test-bucket", SecretKey: "test", }) if err == nil { t.Error("expected error for missing access key") } } func TestS3_FullKey(t *testing.T) { tests := []struct { prefix string key string want string }{ {"", "attachments/ch/f.txt", "attachments/ch/f.txt"}, {"switchboard/", "attachments/ch/f.txt", "switchboard/attachments/ch/f.txt"}, {"prefix", "attachments/ch/f.txt", "prefix/attachments/ch/f.txt"}, // trailing slash added by NewS3 } for _, tt := range tests { s := &S3Store{prefix: tt.prefix} // Normalize prefix same as NewS3 does if s.prefix != "" && s.prefix[len(s.prefix)-1] != '/' { s.prefix += "/" } got := s.fullKey(tt.key) if got != tt.want { t.Errorf("fullKey(%q, %q) = %q, want %q", tt.prefix, tt.key, got, tt.want) } } } func TestS3_KeyValidation(t *testing.T) { // validateKey is shared between PVC and S3 — test with S3 context bad := []string{ "", "../etc/passwd", "/absolute/path", "attachments/../../etc/shadow", } for _, key := range bad { if err := validateKey(key); err == nil { t.Errorf("validateKey(%q) should have failed", key) } } good := []string{ "attachments/ch/f.txt", "attachments/channel-1/att-abc_test.pdf", "processing/abc123/status.json", } for _, key := range good { if err := validateKey(key); err != nil { t.Errorf("validateKey(%q) failed: %v", key, err) } } } // ── Integration Tests (require live S3 endpoint) ────────────── // // Set these environment variables to run: // // S3_TEST_ENDPOINT=localhost:9000 // S3_TEST_BUCKET=switchboard-test // S3_TEST_ACCESS_KEY=minioadmin // S3_TEST_SECRET_KEY=minioadmin // // Example with MinIO: // // docker run -d -p 9000:9000 -p 9001:9001 \ // -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \ // minio/minio server /data --console-address :9001 // mc alias set local http://localhost:9000 minioadmin minioadmin // mc mb local/switchboard-test func testS3Store(t *testing.T) *S3Store { t.Helper() endpoint := os.Getenv("S3_TEST_ENDPOINT") bucket := os.Getenv("S3_TEST_BUCKET") accessKey := os.Getenv("S3_TEST_ACCESS_KEY") secretKey := os.Getenv("S3_TEST_SECRET_KEY") if endpoint == "" || bucket == "" { t.Skip("S3_TEST_ENDPOINT and S3_TEST_BUCKET not set — skipping S3 integration tests") } s, err := NewS3(S3Config{ Endpoint: endpoint, Bucket: bucket, Region: "us-east-1", AccessKey: accessKey, SecretKey: secretKey, Prefix: "test-" + t.Name() + "/", ForcePathStyle: true, }) if err != nil { t.Fatalf("NewS3: %v", err) } // Clean up prefix after test t.Cleanup(func() { ctx := context.Background() _ = s.DeletePrefix(ctx, "attachments") _ = s.DeletePrefix(ctx, "processing") }) return s } func TestS3_Integration_PutGetRoundTrip(t *testing.T) { s := testS3Store(t) ctx := context.Background() data := []byte("hello, S3 storage world") key := "attachments/channel-1/att-1_test.txt" err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain") if err != nil { t.Fatalf("Put: %v", err) } rc, size, ct, err := s.Get(ctx, key) if err != nil { t.Fatalf("Get: %v", err) } defer rc.Close() if size != int64(len(data)) { t.Errorf("size = %d, want %d", size, len(data)) } if ct != "text/plain" { t.Errorf("content_type = %q, want %q", ct, "text/plain") } got, _ := io.ReadAll(rc) if !bytes.Equal(got, data) { t.Errorf("data = %q, want %q", got, data) } } func TestS3_Integration_GetNotFound(t *testing.T) { s := testS3Store(t) ctx := context.Background() _, _, _, err := s.Get(ctx, "attachments/nonexistent/file.txt") if err != ErrNotFound { t.Errorf("err = %v, want ErrNotFound", err) } } func TestS3_Integration_Delete(t *testing.T) { s := testS3Store(t) ctx := context.Background() key := "attachments/ch/delete-me.txt" _ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain") err := s.Delete(ctx, key) if err != nil { t.Fatalf("Delete: %v", err) } exists, _ := s.Exists(ctx, key) if exists { t.Error("file still exists after Delete") } } func TestS3_Integration_DeleteIdempotent(t *testing.T) { s := testS3Store(t) ctx := context.Background() // S3 delete is inherently idempotent err := s.Delete(ctx, "attachments/no-such/file.txt") if err != nil { t.Errorf("Delete nonexistent: %v", err) } } func TestS3_Integration_DeletePrefix(t *testing.T) { s := testS3Store(t) ctx := context.Background() prefix := "attachments/channel-abc/" for _, name := range []string{"a.txt", "b.png", "c.pdf"} { key := prefix + name _ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain") } other := "attachments/channel-other/keep.txt" _ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain") err := s.DeletePrefix(ctx, "attachments/channel-abc") if err != nil { t.Fatalf("DeletePrefix: %v", err) } for _, name := range []string{"a.txt", "b.png", "c.pdf"} { exists, _ := s.Exists(ctx, prefix+name) if exists { t.Errorf("%s still exists after DeletePrefix", name) } } exists, _ := s.Exists(ctx, other) if !exists { t.Error("other channel file was incorrectly deleted") } } func TestS3_Integration_Exists(t *testing.T) { s := testS3Store(t) ctx := context.Background() key := "attachments/ch/exists.txt" _ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain") exists, err := s.Exists(ctx, key) if err != nil { t.Fatalf("Exists: %v", err) } if !exists { t.Error("Exists returned false for existing file") } exists, err = s.Exists(ctx, "attachments/ch/nope.txt") if err != nil { t.Fatalf("Exists (missing): %v", err) } if exists { t.Error("Exists returned true for missing file") } } func TestS3_Integration_Healthy(t *testing.T) { s := testS3Store(t) if err := s.Healthy(context.Background()); err != nil { t.Errorf("Healthy: %v", err) } } func TestS3_Integration_Stats(t *testing.T) { s := testS3Store(t) ctx := context.Background() // Add some files for i := 0; i < 3; i++ { key := "attachments/ch/" + string(rune('a'+i)) + ".txt" data := bytes.Repeat([]byte("x"), 100*(i+1)) _ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain") } stats, err := s.Stats(ctx) if err != nil { t.Fatalf("Stats: %v", err) } if stats.TotalFiles != 3 { t.Errorf("files = %d, want 3", stats.TotalFiles) } if stats.TotalBytes != 600 { t.Errorf("bytes = %d, want 600", stats.TotalBytes) } if !stats.Healthy || !stats.Configured { t.Error("expected healthy + configured") } if stats.Backend != "s3" { t.Errorf("backend = %q, want s3", stats.Backend) } } func TestS3_Integration_PutUnknownSize(t *testing.T) { s := testS3Store(t) ctx := context.Background() data := []byte("unknown size upload") key := "attachments/ch/unknown-size.txt" // size=0 means unknown — S3 should handle via multipart err := s.Put(ctx, key, bytes.NewReader(data), 0, "text/plain") if err != nil { t.Fatalf("Put with size=0: %v", err) } rc, size, _, err := s.Get(ctx, key) if err != nil { t.Fatalf("Get: %v", err) } defer rc.Close() if size != int64(len(data)) { t.Errorf("size = %d, want %d", size, len(data)) } }