// Copyright 2019-present Facebook Inc. All rights reserved. // This source code is licensed under the Apache 2.0 license found // in the LICENSE file in the root directory of this source tree. package integration import ( "bytes" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "os" "path/filepath" "strings" "testing" "entgo.io/ent/dialect" "entgo.io/ent/entc/integration/blob" "entgo.io/ent/entc/integration/ent" "entgo.io/ent/entc/integration/ent/document" "entgo.io/ent/entc/integration/ent/enttest" "entgo.io/ent/entc/integration/ent/migrate" _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/require" _ "gocloud.dev/blob/fileblob" ) // readBlob reads all data from a blob field reader and closes it. func readBlob(t *testing.T, rc io.ReadCloser, err error) []byte { t.Helper() require.NoError(t, err) if rc == nil { return nil } data, readErr := io.ReadAll(rc) require.NoError(t, readErr) require.NoError(t, rc.Close()) return data } // blobContent is a shorthand for readBlob(t, entity.Field(ctx)). func blobContent(t *testing.T, fn func(context.Context) (io.ReadCloser, error), ctx context.Context) []byte { t.Helper() rc, err := fn(ctx) return readBlob(t, rc, err) } // blobDir creates a temp directory with subdirectories for blob fields. func blobDir(t *testing.T) string { t.Helper() dir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(dir, "documents"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(dir, "thumbnails"), 0o755)) return dir } // newBlobOpeners returns BlobOpeners that route to the correct // file:// bucket based on the field name, using absolute paths under dir. func newBlobOpeners(dir string) ent.BlobOpeners { return ent.BlobOpeners{ Document: func(ctx context.Context, field string) (ent.Blob, error) { switch field { case document.FieldContent: return blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "documents")) case document.FieldThumbnail: return blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "thumbnails")) default: return nil, fmt.Errorf("unknown blob field: %s", field) } }, } } // setupBlob creates a temp directory with subdirectories for each blob field, // opens an in-memory SQLite client with auto-migration, and registers cleanup. func setupBlob(t *testing.T, opts ...ent.Option) (*ent.Client, context.Context, string) { t.Helper() dir := blobDir(t) allOpts := append([]ent.Option{ent.WithBlobOpeners(newBlobOpeners(dir))}, opts...) entOpts := []enttest.Option{ enttest.WithMigrateOptions(migrate.WithDropIndex(true), migrate.WithDropColumn(true)), enttest.WithOptions(allOpts...), } client := enttest.Open(t, dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1", entOpts..., ) t.Cleanup(func() { client.Close() }) return client, context.Background(), dir } func TestBlobCreateAndRead(t *testing.T) { client, ctx, _ := setupBlob(t) data := []byte("Hello from blob integration test!") doc := client.Document.Create(). SetName("test-doc"). SetContent(bytes.NewReader(data)). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) // Read the blob back through the entity method. got := blobContent(t, doc.Content, ctx) require.Equal(t, data, got) } func TestBlobQueryAndRead(t *testing.T) { client, ctx, _ := setupBlob(t) data := []byte("queried blob content") created := client.Document.Create(). SetName("query-doc"). SetContent(bytes.NewReader(data)). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) // Query it back from the database (no content column, just ID/name). queried := client.Document.GetX(ctx, created.ID) // Read blob content from the queried entity. got := blobContent(t, queried.Content, ctx) require.Equal(t, data, got) } func TestBlobUpdateData(t *testing.T) { client, ctx, _ := setupBlob(t) v1 := []byte("version 1") doc := client.Document.Create(). SetName("update-doc"). SetContent(bytes.NewReader(v1)). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) // Update blob data through mutation (overwrites same key). v2 := []byte("version 2 - updated via mutation") doc = doc.Update(). SetContent(bytes.NewReader(v2)). SaveX(ctx) // Read the new blob content. got := blobContent(t, doc.Content, ctx) require.Equal(t, v2, got) } func TestBlobRequiredValidation(t *testing.T) { client, ctx, _ := setupBlob(t) // Creating a document without required blob fields should fail. _, err := client.Document.Create(). SetName("no-blob-doc"). Save(ctx) require.Error(t, err) require.Contains(t, err.Error(), "missing required field") } func TestBlobMultipleDocuments(t *testing.T) { client, ctx, _ := setupBlob(t) contents := []string{ "document one content", "document two content - larger payload with more data", "document three", } var ids []int for i, c := range contents { doc := client.Document.Create(). SetName("multi-" + string(rune('a'+i))). SetContent(strings.NewReader(c)). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) ids = append(ids, doc.ID) } // Read each document and verify content is correct. for i, id := range ids { doc := client.Document.GetX(ctx, id) got := blobContent(t, doc.Content, ctx) require.Equal(t, contents[i], string(got), "document %d content mismatch", i) } } func TestBlobBulkCreate(t *testing.T) { client, ctx, _ := setupBlob(t) bulk := make([]*ent.DocumentCreate, 5) for i := range bulk { bulk[i] = client.Document.Create(). SetName(strings.Repeat("bulk-", i+1)). SetContent(bytes.NewReader([]byte(strings.Repeat("x", i+1)))). SetThumbnail(bytes.NewReader([]byte("thumb"))) } docs, err := client.Document.CreateBulk(bulk...).Save(ctx) require.NoError(t, err) require.Len(t, docs, 5) // Verify each document's blob can be read back with correct data. for i, doc := range docs { got := blobContent(t, doc.Content, ctx) require.Equal(t, []byte(strings.Repeat("x", i+1)), got) } } func TestBlobThumbnailCreateAndRead(t *testing.T) { client, ctx, _ := setupBlob(t) thumbData := []byte("fake-png-thumbnail-data") doc := client.Document.Create(). SetName("doc-with-thumb"). SetContent(bytes.NewReader([]byte("content"))). SetThumbnail(bytes.NewReader(thumbData)). SaveX(ctx) got := blobContent(t, doc.Thumbnail, ctx) require.Equal(t, thumbData, got) } func TestBlobBothFields(t *testing.T) { client, ctx, _ := setupBlob(t) contentData := []byte("document body content") thumbData := []byte("thumbnail image bytes") doc := client.Document.Create(). SetName("doc-both"). SetContent(bytes.NewReader(contentData)). SetThumbnail(bytes.NewReader(thumbData)). SaveX(ctx) // Read content. cGot := blobContent(t, doc.Content, ctx) require.Equal(t, contentData, cGot) // Read thumbnail. tGot := blobContent(t, doc.Thumbnail, ctx) require.Equal(t, thumbData, tGot) // Update only thumbnail, content should remain unchanged. newThumb := []byte("updated thumbnail") doc = doc.Update(). SetThumbnail(bytes.NewReader(newThumb)). SaveX(ctx) cGot2 := blobContent(t, doc.Content, ctx) require.Equal(t, contentData, cGot2) tGot2 := blobContent(t, doc.Thumbnail, ctx) require.Equal(t, newThumb, tGot2) } func TestBlobBulkCreateBothFields(t *testing.T) { client, ctx, _ := setupBlob(t) bulk := make([]*ent.DocumentCreate, 3) for i := range bulk { bulk[i] = client.Document.Create(). SetName(strings.Repeat("bulk-both-", i+1)). SetContent(bytes.NewReader([]byte(strings.Repeat("c", i+1)))). SetThumbnail(bytes.NewReader([]byte(strings.Repeat("t", i+1)))) } docs, err := client.Document.CreateBulk(bulk...).Save(ctx) require.NoError(t, err) require.Len(t, docs, 3) for i, doc := range docs { cGot := blobContent(t, doc.Content, ctx) require.Equal(t, []byte(strings.Repeat("c", i+1)), cGot) tGot := blobContent(t, doc.Thumbnail, ctx) require.Equal(t, []byte(strings.Repeat("t", i+1)), tGot) } } func TestBlobWriter(t *testing.T) { client, ctx, _ := setupBlob(t) // Create a document with content via mutation. doc := client.Document.Create(). SetName("writer-doc"). SetContent(strings.NewReader("initial")). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) // Overwrite via ContentWriter (bypasses mutation pipeline). w, err := doc.ContentWriter(ctx) require.NoError(t, err) _, err = io.Copy(w, strings.NewReader("overwritten via writer")) require.NoError(t, err) require.NoError(t, w.Close()) // Read back should reflect the overwritten data. got := blobContent(t, doc.Content, ctx) require.Equal(t, []byte("overwritten via writer"), got) } func TestBlobReader(t *testing.T) { client, ctx, _ := setupBlob(t) data := []byte("streaming read test data") doc := client.Document.Create(). SetName("reader-doc"). SetContent(bytes.NewReader(data)). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) // Read back via Content (io.ReadCloser). r, err := doc.Content(ctx) require.NoError(t, err) defer r.Close() got, err := io.ReadAll(r) require.NoError(t, err) require.Equal(t, data, got) } func TestBlobWriterThenReader(t *testing.T) { client, ctx, _ := setupBlob(t) // Create a document with initial content, then overwrite via Writer. doc := client.Document.Create(). SetName("write-then-read"). SetContent(strings.NewReader("initial")). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) // Write via writer. w, err := doc.ContentWriter(ctx) require.NoError(t, err) _, err = w.Write([]byte("hello ")) require.NoError(t, err) _, err = w.Write([]byte("world")) require.NoError(t, err) require.NoError(t, w.Close()) // Read via reader. got := blobContent(t, doc.Content, ctx) require.Equal(t, []byte("hello world"), got) } func TestBlobKey(t *testing.T) { client, ctx, _ := setupBlob(t) doc := client.Document.Create(). SetName("key-doc"). SetContent(bytes.NewReader([]byte("content"))). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) // Content key follows the convention {table}/{id}/{field}. contentKey, err := doc.ContentKey(ctx) require.NoError(t, err) require.Equal(t, fmt.Sprintf("documents/%d/content", doc.ID), contentKey) // Thumbnail key uses a custom hash-based format (template override). thumbnailKey, err := doc.ThumbnailKey(ctx) require.NoError(t, err) h := sha256.Sum256([]byte(fmt.Sprintf("documents/%d/thumbnail", doc.ID))) require.Equal(t, hex.EncodeToString(h[:]), thumbnailKey) } func TestBlobWriterOptions(t *testing.T) { // Verify that a custom opener with WriterOptions works for roundtrip. dir := blobDir(t) openerWithOpts := ent.BlobOpeners{ Document: func(ctx context.Context, field string) (ent.Blob, error) { b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "documents")) if err != nil { return nil, err } return b.WithWriterOptions(nil), nil }, } client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(openerWithOpts)) data := []byte("content with writer options") doc := client.Document.Create(). SetName("opts-doc"). SetContent(bytes.NewReader(data)). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) got := blobContent(t, doc.Content, ctx) require.Equal(t, data, got) } func TestBlobWriterOptionsApplied(t *testing.T) { // Verify that WriterOptions are applied on both create and update. dir := blobDir(t) openerWithOpts := ent.BlobOpeners{ Document: func(ctx context.Context, field string) (ent.Blob, error) { b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "documents")) if err != nil { return nil, err } return b.WithWriterOptions(nil), nil }, } client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(openerWithOpts)) data := []byte("content with writer options") doc := client.Document.Create(). SetName("opts-doc"). SetContent(bytes.NewReader(data)). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) // Verify the data was written successfully and can be read back. got := blobContent(t, doc.Content, ctx) require.Equal(t, data, got) // Update also uses WriterOpts. v2 := []byte("updated with writer options") doc = doc.Update().SetContent(bytes.NewReader(v2)).SaveX(ctx) got = blobContent(t, doc.Content, ctx) require.Equal(t, v2, got) } func TestBlobEncryption(t *testing.T) { // Demonstrate per-tenant encryption using a master seed + tenant from context. // Each tenant derives a unique AES-256 key via SHA-256(tenant || seed). // Data written by one tenant cannot be decrypted by another. dir := blobDir(t) // Master seed shared across all tenants (kept secret server-side). masterSeed := []byte("super-secret-master-seed-for-test") encryptedOpeners := ent.BlobOpeners{ Document: func(ctx context.Context, field string) (ent.Blob, error) { var subdir string switch field { case document.FieldContent: subdir = "documents" case document.FieldThumbnail: subdir = "thumbnails" default: return nil, fmt.Errorf("unknown blob field: %s", field) } b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, subdir)) if err != nil { return nil, err } // Wrap the bucket with per-tenant encryption. return blob.NewEncrypted(b, masterSeed), nil }, } // Tenant "acme" writes encrypted data. client, _, _ := setupBlob(t, ent.WithBlobOpeners(encryptedOpeners)) acmeCtx := blob.WithTenant(context.Background(), "acme") plaintext := []byte("top secret document content for acme") doc := client.Document.Create(). SetName("encrypted-doc"). SetContent(bytes.NewReader(plaintext)). SetThumbnail(bytes.NewReader([]byte("secret-thumb"))). SaveX(acmeCtx) // Reading with the same tenant returns decrypted plaintext. got := blobContent(t, doc.Content, acmeCtx) require.Equal(t, plaintext, got) gotThumb := blobContent(t, doc.Thumbnail, acmeCtx) require.Equal(t, []byte("secret-thumb"), gotThumb) // Verify the raw file on disk is NOT plaintext (it's encrypted). contentKey, err := doc.ContentKey(acmeCtx) require.NoError(t, err) rawPath := filepath.Join(dir, "documents", contentKey) raw, err := os.ReadFile(rawPath) require.NoError(t, err) require.NotEqual(t, plaintext, raw, "raw data on disk should be encrypted") // The raw data should be longer than plaintext (16-byte IV prepended). require.Equal(t, len(plaintext)+16, len(raw)) // A different tenant ("evil") cannot decrypt acme's data — the derived key // differs, so AES-CTR produces garbage (not the original plaintext). evilCtx := blob.WithTenant(context.Background(), "evil") evilData := blobContent(t, doc.Content, evilCtx) require.NotEqual(t, plaintext, evilData, "different tenant must not read acme's plaintext") // No tenant in context → error. noTenantCtx := context.Background() _, err = doc.Content(noTenantCtx) require.Error(t, err) require.Contains(t, err.Error(), "requires a tenant") // Update works through encryption too. v2 := []byte("updated secret content") doc = doc.Update().SetContent(bytes.NewReader(v2)).SaveX(acmeCtx) got = blobContent(t, doc.Content, acmeCtx) require.Equal(t, v2, got) // ContentWriter also encrypts with tenant key. w, err := doc.ContentWriter(acmeCtx) require.NoError(t, err) _, err = w.Write([]byte("written via writer")) require.NoError(t, err) require.NoError(t, w.Close()) got = blobContent(t, doc.Content, acmeCtx) require.Equal(t, []byte("written via writer"), got) } func TestBlobPrefix(t *testing.T) { dir := blobDir(t) openers := newBlobOpeners(dir) prefixedOpeners := ent.BlobOpeners{ Document: func(ctx context.Context, field string) (ent.Blob, error) { if field == "content" { b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "documents")) if err != nil { return nil, err } return b.Prefixed("tenant-1/"), nil } return openers.Document(ctx, field) }, } client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(prefixedOpeners)) data := []byte("prefixed blob content") doc := client.Document.Create(). SetName("prefixed-doc"). SetContent(bytes.NewReader(data)). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) // Read through the entity — uses the same opener. got := blobContent(t, doc.Content, ctx) require.Equal(t, data, got) // Create a second client with the default opener — reading should return nil (not found). defaultClient := enttest.Open(t, dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1", enttest.WithOptions(ent.WithBlobOpeners(openers)), ) t.Cleanup(func() { defaultClient.Close() }) doc2 := defaultClient.Document.Query().OnlyX(ctx) got = blobContent(t, doc2.Content, ctx) require.Nil(t, got, "expected nil when reading without prefix") } func TestBlobPrefixUpdate(t *testing.T) { dir := blobDir(t) openers := newBlobOpeners(dir) prefixedOpeners := ent.BlobOpeners{ Document: func(ctx context.Context, field string) (ent.Blob, error) { if field == "content" { b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "documents")) if err != nil { return nil, err } return b.Prefixed("tenant-2/"), nil } return openers.Document(ctx, field) }, } client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(prefixedOpeners)) data := []byte("initial") doc := client.Document.Create(). SetName("prefix-update-doc"). SetContent(bytes.NewReader(data)). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) updated := []byte("updated under prefix") doc = doc.Update().SetContent(bytes.NewReader(updated)).SaveX(ctx) got := blobContent(t, doc.Content, ctx) require.Equal(t, updated, got) } func TestBlobPrefixBulkCreate(t *testing.T) { dir := blobDir(t) openers := newBlobOpeners(dir) prefixedOpeners := ent.BlobOpeners{ Document: func(ctx context.Context, field string) (ent.Blob, error) { if field == "content" { b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "documents")) if err != nil { return nil, err } return b.Prefixed("bulk-tenant/"), nil } return openers.Document(ctx, field) }, } client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(prefixedOpeners)) docs := client.Document.CreateBulk( client.Document.Create().SetName("bulk-p1").SetContent(strings.NewReader("b1")).SetThumbnail(bytes.NewReader([]byte("t1"))), client.Document.Create().SetName("bulk-p2").SetContent(strings.NewReader("b2")).SetThumbnail(bytes.NewReader([]byte("t2"))), ).SaveX(ctx) for i, doc := range docs { got := blobContent(t, doc.Content, ctx) require.Equal(t, []byte(fmt.Sprintf("b%d", i+1)), got) } } func TestBlobPrefixWriterReader(t *testing.T) { dir := blobDir(t) openers := newBlobOpeners(dir) prefixedOpeners := ent.BlobOpeners{ Document: func(ctx context.Context, field string) (ent.Blob, error) { if field == "content" { b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, "documents")) if err != nil { return nil, err } return b.Prefixed("rw-tenant/"), nil } return openers.Document(ctx, field) }, } client, ctx, _ := setupBlob(t, ent.WithBlobOpeners(prefixedOpeners)) doc := client.Document.Create(). SetName("prefix-rw-doc"). SetContent(strings.NewReader("initial")). SetThumbnail(bytes.NewReader([]byte("thumb"))). SaveX(ctx) // Write via ContentWriter. w, err := doc.ContentWriter(ctx) require.NoError(t, err) _, err = io.Copy(w, strings.NewReader("writer-prefixed")) require.NoError(t, err) require.NoError(t, w.Close()) // Read via Content. got := blobContent(t, doc.Content, ctx) require.Equal(t, []byte("writer-prefixed"), got) }