This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/storage/s3_test.go
Jeffrey Smith 8e40f04445 Delete dead test helpers and rename vestigial test paths
- Remove seed_helpers.go (SeedTestMessage, SeedTestMessages,
  SeedTestCursor — all unused, channel/message features gutted)
- Rename channel- prefixed test paths to test- in storage tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:06:40 +00:00

327 lines
7.5 KiB
Go

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
}{
{"", "files/ch/f.txt", "files/ch/f.txt"},
{"switchboard/", "files/ch/f.txt", "switchboard/files/ch/f.txt"},
{"prefix", "files/ch/f.txt", "prefix/files/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",
"files/../../etc/shadow",
}
for _, key := range bad {
if err := validateKey(key); err == nil {
t.Errorf("validateKey(%q) should have failed", key)
}
}
good := []string{
"files/ch/f.txt",
"files/test-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, "files")
_ = 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 := "files/test-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, "files/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 := "files/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, "files/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 := "files/test-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 := "files/test-other/keep.txt"
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
err := s.DeletePrefix(ctx, "files/test-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 := "files/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, "files/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 := "files/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 := "files/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))
}
}