Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (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
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (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 & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
282 lines
11 KiB
Go
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, "", "")
|
|
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)
|
|
}
|