Files
yiandCursor 9fb2c1321a
App / Format, vet, test, and build (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
fix: store digital-human upload text in files, slim jsonb for list
Avoid pulling multi-MB result payloads on uploads?view=summary over remote DB, which caused 30s client timeouts and HTTP 500.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 19:20:13 +08:00

282 lines
11 KiB
Go

package resumable
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func uploadContext(tenant uint64, user string) context.Context {
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, tenant)
return context.WithValue(ctx, types.UserIDContextKey, user)
}
func uploadService(t *testing.T) *Service {
t.Helper()
db, e := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "uploads.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, e)
require.NoError(t, db.AutoMigrate(&Upload{}, &Chunk{}))
require.NoError(t, db.Exec("CREATE TABLE tenants(id INTEGER PRIMARY KEY,storage_quota BIGINT,storage_used BIGINT,deleted_at DATETIME)").Error)
require.NoError(t, db.Exec("INSERT INTO tenants(id,storage_quota,storage_used) VALUES(1,0,0),(2,0,0)").Error)
sql, e := db.DB()
require.NoError(t, e)
sql.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sql.Close() })
s, e := NewService(db, t.TempDir(), func(context.Context, string, string) error { return nil }, func(ctx context.Context, _ *Upload) (context.Context, error) { return ctx, nil }, func(_ context.Context, u *Upload, _ string) (int64, types.JSON, bool, error) {
return u.Size, types.JSON(`{"ok":true}`), true, nil
})
require.NoError(t, e)
return s
}
func sum(data []byte) string { h := sha256.Sum256(data); return hex.EncodeToString(h[:]) }
func create(t *testing.T, s *Service, data []byte) *Upload {
t.Helper()
u, e := s.Create(uploadContext(1, "alice"), CreateInput{Kind: "knowledge", TargetID: "kb", Name: "fixture.txt", Size: int64(len(data)), SHA256: sum(data)})
require.NoError(t, e)
return u
}
func TestUploadResumeChecksumOwnershipAndCrashRecovery(t *testing.T) {
s := uploadService(t)
ctx := uploadContext(1, "alice")
data := []byte("abcdef")
u := create(t, s, data)
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Update("chunk_size", 3).Error)
_, e := s.Put(ctx, u.ID, 0, 3, sum([]byte("wrong")), bytes.NewReader(data[:3]))
require.ErrorIs(t, e, ErrInvalid)
row, e := s.Get(ctx, u.ID)
require.NoError(t, e)
require.Zero(t, row.Offset)
_, e = s.Put(uploadContext(1, "bob"), u.ID, 0, 3, sum(data[:3]), bytes.NewReader(data[:3]))
require.ErrorIs(t, e, gorm.ErrRecordNotFound)
_, e = s.Get(uploadContext(2, "alice"), u.ID)
require.ErrorIs(t, e, gorm.ErrRecordNotFound)
row, e = s.Put(ctx, u.ID, 0, 3, sum(data[:3]), bytes.NewReader(data[:3]))
require.NoError(t, e)
require.EqualValues(t, 3, row.Offset)
chunks, e := s.Chunks(ctx, u.ID, 0)
require.NoError(t, e)
require.Len(t, chunks, 1)
require.Equal(t, sum(data[:3]), chunks[0].SHA256)
_, e = s.Chunks(uploadContext(1, "bob"), u.ID, 0)
require.ErrorIs(t, e, gorm.ErrRecordNotFound)
_, e = s.Chunks(ctx, u.ID, 1)
require.ErrorIs(t, e, ErrInvalid)
// A response lost after commit may replay exactly the same chunk.
row, e = s.Put(ctx, u.ID, 0, 3, sum(data[:3]), bytes.NewReader(data[:3]))
require.NoError(t, e)
require.EqualValues(t, 3, row.Offset)
_, e = s.Put(ctx, u.ID, 0, 3, sum(data[3:]), bytes.NewReader(data[3:]))
require.ErrorIs(t, e, ErrConflict)
_, e = s.Finalize(ctx, u.ID)
require.ErrorIs(t, e, ErrConflict)
// Bytes written before a killed transaction must not survive the resume.
f, e := os.OpenFile(s.path(u.ID), os.O_APPEND|os.O_WRONLY, 0600)
require.NoError(t, e)
_, e = f.WriteString("uncommitted")
require.NoError(t, e)
require.NoError(t, f.Close())
restarted, e := NewService(s.db, s.root, s.authorize, s.workerContext, s.ingest)
require.NoError(t, e)
row, e = restarted.Put(ctx, u.ID, 3, 3, sum(data[3:]), bytes.NewReader(data[3:]))
require.NoError(t, e)
require.EqualValues(t, 6, row.Offset)
stored, e := os.ReadFile(s.path(u.ID))
require.NoError(t, e)
require.Equal(t, data, stored)
row, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
require.Equal(t, "queued", row.State)
_, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
worked, e := s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
row, e = s.Get(ctx, u.ID)
require.NoError(t, e)
require.Equal(t, "completed", row.State)
require.EqualValues(t, 6, row.IngestOffset)
}
func TestUploadBoundsQuotaCancelAndExpiry(t *testing.T) {
s := uploadService(t)
ctx := uploadContext(1, "alice")
in := CreateInput{Kind: "knowledge", TargetID: "kb", Name: "large.txt", Size: MaxFileSize + 1}
_, e := s.Create(ctx, in)
require.ErrorIs(t, e, ErrInvalid)
in.Size = MaxFileSize
first, e := s.Create(ctx, in)
require.NoError(t, e)
require.Equal(t, MaxFileSize, first.Size)
_, e = s.Create(ctx, in)
require.NoError(t, e)
in.Size = 1
_, e = s.Create(ctx, in)
require.ErrorIs(t, e, ErrQuota)
require.NoError(t, s.Cancel(ctx, first.ID))
small, e := s.Create(ctx, in)
require.NoError(t, e)
_, e = s.Put(ctx, small.ID, 0, ChunkSize+1, strings.Repeat("a", 64), strings.NewReader("x"))
require.ErrorIs(t, e, ErrInvalid)
_, e = s.Put(ctx, small.ID, 0, 1, sum([]byte("x")), strings.NewReader("xx"))
require.ErrorIs(t, e, ErrInvalid)
_, e = s.Put(ctx, small.ID, 0, 1, sum([]byte("x")), strings.NewReader("x"))
require.NoError(t, e)
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", small.ID).Update("expires_at", time.Now().Add(-time.Hour)).Error)
_, e = s.Finalize(ctx, small.ID)
require.ErrorIs(t, e, ErrConflict)
require.NoError(t, s.Cleanup(ctx))
_, e = os.Stat(s.path(small.ID))
require.True(t, os.IsNotExist(e))
row, e := s.Get(ctx, small.ID)
require.NoError(t, e)
require.Equal(t, "expired", row.State)
var chunks int64
require.NoError(t, s.db.Model(&Chunk{}).Where("upload_id=?", small.ID).Count(&chunks).Error)
require.Zero(t, chunks)
}
func TestUploadWorkerChecksWholeFileAndReauthorizes(t *testing.T) {
for _, scenario := range []string{"corruption", "revoked"} {
t.Run(scenario, func(t *testing.T) {
s := uploadService(t)
ctx := uploadContext(1, "alice")
data := []byte("source")
u := create(t, s, data)
_, e := s.Put(ctx, u.ID, 0, int64(len(data)), sum(data), bytes.NewReader(data))
require.NoError(t, e)
_, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
called := false
s.ingest = func(context.Context, *Upload, string) (int64, types.JSON, bool, error) {
called = true
return 6, types.JSON(`{}`), true, nil
}
if scenario == "corruption" {
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Update("sha256", "").Error)
require.NoError(t, os.WriteFile(s.path(u.ID), []byte("edited"), 0600))
} else {
s.authorize = func(context.Context, string, string) error { return ErrForbidden }
}
worked, e := s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
var row Upload
require.NoError(t, s.db.Where("id=?", u.ID).Take(&row).Error)
require.Equal(t, "failed", row.State)
require.False(t, called)
})
}
}
func TestUploadRevocationHidesEvidenceAndStopsWritesButAllowsCleanup(t *testing.T) {
s := uploadService(t)
ctx := uploadContext(1, "alice")
u := create(t, s, []byte("source"))
_, e := s.Put(ctx, u.ID, 0, 6, sum([]byte("source")), strings.NewReader("source"))
require.NoError(t, e)
s.authorize = func(context.Context, string, string) error { return ErrForbidden }
_, e = s.Get(ctx, u.ID)
require.ErrorIs(t, e, ErrForbidden)
_, e = s.Put(ctx, u.ID, 0, 6, sum([]byte("source")), strings.NewReader("source"))
require.ErrorIs(t, e, ErrForbidden)
_, e = s.Finalize(ctx, u.ID)
require.ErrorIs(t, e, ErrForbidden)
rows, e := s.List(ctx, "", "", false)
require.NoError(t, e)
require.Empty(t, rows)
require.NoError(t, s.Cancel(ctx, u.ID))
var count int64
require.NoError(t, s.db.Model(&Chunk{}).Where("upload_id=?", u.ID).Count(&count).Error)
require.Zero(t, count)
_, e = os.Stat(s.path(u.ID))
require.True(t, os.IsNotExist(e))
}
func TestUploadWorkerDurableUnitsAndExpiredLease(t *testing.T) {
s := uploadService(t)
ctx := uploadContext(1, "alice")
u := create(t, s, []byte("ab"))
_, e := s.Put(ctx, u.ID, 0, 2, sum([]byte("ab")), strings.NewReader("ab"))
require.NoError(t, e)
_, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
s.ingest = func(_ context.Context, row *Upload, _ string) (int64, types.JSON, bool, error) {
return row.IngestOffset + 1, types.JSON(`{"checkpoint":true}`), row.IngestOffset == 1, nil
}
worked, e := s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
row, e := s.Get(ctx, u.ID)
require.NoError(t, e)
require.EqualValues(t, 1, row.IngestOffset)
require.Equal(t, "processing", row.State)
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Updates(map[string]any{"lease_until": time.Now().Add(time.Hour), "lease_token": "crashed-worker"}).Error)
worked, e = s.ProcessNext(context.Background())
require.NoError(t, e)
require.False(t, worked)
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Update("lease_until", time.Now().Add(-time.Minute)).Error)
worked, e = s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
row, e = s.Get(ctx, u.ID)
require.NoError(t, e)
require.Equal(t, "completed", row.State)
require.EqualValues(t, 2, row.IngestOffset)
}
func TestUploadWorkerAndHTTPNeverExposeInternalFailures(t *testing.T) {
const secret = "postgres://private:credential-sentinel@internal-host/database"
s := uploadService(t)
ctx := uploadContext(1, "alice")
u := create(t, s, []byte("ok"))
_, e := s.Put(ctx, u.ID, 0, 2, sum([]byte("ok")), strings.NewReader("ok"))
require.NoError(t, e)
_, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
s.ingest = func(context.Context, *Upload, string) (int64, types.JSON, bool, error) {
return 0, nil, false, errors.New(secret)
}
worked, e := s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
row, e := s.Get(ctx, u.ID)
require.NoError(t, e)
require.Equal(t, "failed", row.State)
require.NotContains(t, row.Error, "credential-sentinel")
require.Contains(t, row.Error, "服务日志")
r := gin.New()
r.GET("/error", func(c *gin.Context) { respond(c, 200, nil, errors.New(secret)) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/error", nil))
require.Equal(t, 500, w.Code)
require.NotContains(t, w.Body.String(), "credential-sentinel")
// A retry preserves the completed transport and checkpoint.
_, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
s.ingest = func(_ context.Context, row *Upload, _ string) (int64, types.JSON, bool, error) {
return row.Size, types.JSON(`{}`), true, nil
}
worked, e = s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
row, e = s.Get(ctx, u.ID)
require.NoError(t, e)
require.Equal(t, "completed", row.State)
require.Empty(t, row.Error)
}