Files
MyGoNavi/internal/app/sql_import_source_test.go
Syngnat 390a249105 feat(data-import): 重构大文件导入与安全任务运行时
- SQL、CSV、JSON 与 XLSX 改为资源受限的流式解析,并支持压缩、编码和方言预检

- 增加遇错停止/继续策略、持久任务、错误行导出、源文件身份与安全取消

- 为数据库驱动补齐上下文事务和写入结果未知语义,避免失败重放与脏会话复用
2026-08-08 20:51:23 +08:00

241 lines
7.3 KiB
Go

package app
import (
"bytes"
"compress/gzip"
"encoding/binary"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"unicode/utf16"
)
func TestOpenSQLImportSourceStripsUTF8BOM(t *testing.T) {
path := filepath.Join(t.TempDir(), "dump.sql")
if err := os.WriteFile(path, append([]byte{0xef, 0xbb, 0xbf}, []byte("SELECT 1;")...), 0o600); err != nil {
t.Fatalf("write source: %v", err)
}
source, err := OpenSQLImportSource(path, SQLImportSourceOptions{})
if err != nil {
t.Fatalf("open SQL import source: %v", err)
}
defer source.Close()
got, err := io.ReadAll(source)
if err != nil {
t.Fatalf("read SQL import source: %v", err)
}
if string(got) != "SELECT 1;" {
t.Fatalf("decoded source = %q, want BOM-free UTF-8", got)
}
}
func TestOpenSQLImportSourceDecodesUTF16LE(t *testing.T) {
path := filepath.Join(t.TempDir(), "dump.sql")
if err := os.WriteFile(path, encodeUTF16SQL("SELECT '中文';", binary.LittleEndian), 0o600); err != nil {
t.Fatalf("write source: %v", err)
}
source, err := OpenSQLImportSource(path, SQLImportSourceOptions{})
if err != nil {
t.Fatalf("open SQL import source: %v", err)
}
defer source.Close()
got, err := io.ReadAll(source)
if err != nil {
t.Fatalf("read SQL import source: %v", err)
}
if string(got) != "SELECT '中文';" || source.Encoding != "utf-16le" {
t.Fatalf("decoded source = %q (%s), want UTF-16LE decoded as UTF-8", got, source.Encoding)
}
}
func TestOpenSQLImportSourceDecodesUTF16BE(t *testing.T) {
path := filepath.Join(t.TempDir(), "dump.sql")
if err := os.WriteFile(path, encodeUTF16SQL("SELECT '中文';", binary.BigEndian), 0o600); err != nil {
t.Fatalf("write source: %v", err)
}
source, err := OpenSQLImportSource(path, SQLImportSourceOptions{})
if err != nil {
t.Fatalf("open SQL import source: %v", err)
}
defer source.Close()
got, err := io.ReadAll(source)
if err != nil {
t.Fatalf("read SQL import source: %v", err)
}
if string(got) != "SELECT '中文';" || source.Encoding != "utf-16be" {
t.Fatalf("decoded source = %q (%s), want UTF-16BE decoded as UTF-8", got, source.Encoding)
}
}
func TestOpenSQLImportSourceStreamsGzipBeforeEncodingDetection(t *testing.T) {
path := filepath.Join(t.TempDir(), "dump.sql.gz")
writeGzipSQL(t, path, append([]byte{0xef, 0xbb, 0xbf}, []byte("SELECT 1;")...))
source, err := OpenSQLImportSource(path, SQLImportSourceOptions{})
if err != nil {
t.Fatalf("open SQL import source: %v", err)
}
defer source.Close()
got, err := io.ReadAll(source)
if err != nil {
t.Fatalf("read SQL import source: %v", err)
}
if string(got) != "SELECT 1;" || !source.Compressed {
t.Fatalf("decoded source = %q (compressed=%v), want streamed gzip SQL", got, source.Compressed)
}
}
func TestOpenSQLImportSourceReportsAndObservesRawCompressedBytes(t *testing.T) {
path := filepath.Join(t.TempDir(), "dump.sql.gz")
writeGzipSQL(t, path, []byte("SELECT 1;"))
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read raw source: %v", err)
}
var observed bytes.Buffer
source, err := OpenSQLImportSource(path, SQLImportSourceOptions{RawObserver: &observed})
if err != nil {
t.Fatalf("open SQL import source: %v", err)
}
defer source.Close()
if _, err := io.ReadAll(source); err != nil {
t.Fatalf("read SQL import source: %v", err)
}
if source.RawBytesRead() != int64(len(raw)) {
t.Fatalf("raw bytes read = %d, want compressed size %d", source.RawBytesRead(), len(raw))
}
if !bytes.Equal(observed.Bytes(), raw) {
t.Fatal("raw observer did not receive the original compressed bytes")
}
}
func TestOpenSQLImportSourceEnforcesDecodedByteLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "dump.sql")
if err := os.WriteFile(path, []byte("SELECT 1234567890;"), 0o600); err != nil {
t.Fatalf("write source: %v", err)
}
source, err := OpenSQLImportSource(path, SQLImportSourceOptions{MaxDecodedBytes: 8})
if err != nil {
t.Fatalf("open SQL import source: %v", err)
}
defer source.Close()
got, readErr := io.ReadAll(source)
var limitErr *SQLImportSourceLimitError
if !errors.As(readErr, &limitErr) || limitErr.Kind != SQLImportSourceDecodedByteLimit {
t.Fatalf("read error = %v, want decoded-byte limit error", readErr)
}
if len(got) > 8 || limitErr.DecodedBytes != 8 || limitErr.Limit != 8 {
t.Fatalf("read %d bytes, error = %#v; want no bytes beyond limit", len(got), limitErr)
}
}
func TestOpenSQLImportSourceRejectsExcessiveCompressionRatio(t *testing.T) {
path := filepath.Join(t.TempDir(), "dump.sql.gz")
writeGzipSQL(t, path, []byte(strings.Repeat("INSERT INTO t VALUES (1);\n", 4096)))
source, err := OpenSQLImportSource(path, SQLImportSourceOptions{
MaxDecodedBytes: 1 << 20,
MaxCompressionRatio: 2,
MinCompressedBytesForRatio: 1,
})
if err != nil {
t.Fatalf("open SQL import source: %v", err)
}
defer source.Close()
_, readErr := io.ReadAll(source)
var limitErr *SQLImportSourceLimitError
if !errors.As(readErr, &limitErr) || limitErr.Kind != SQLImportSourceCompressionRatio {
t.Fatalf("read error = %v, want compression-ratio limit error", readErr)
}
if limitErr.Ratio <= 2 || limitErr.MaxCompressionRatio != 2 || limitErr.CompressedBytes <= 0 {
t.Fatalf("limit error = %#v, want measured ratio above limit", limitErr)
}
}
func TestOpenSQLImportSourceDefaultCompressionRatioProtectsSmallGzipBomb(t *testing.T) {
path := filepath.Join(t.TempDir(), "dump.sql.gz")
writeGzipSQL(t, path, []byte(strings.Repeat("A", 8<<20)))
source, err := OpenSQLImportSource(path, SQLImportSourceOptions{})
if err != nil {
t.Fatalf("open SQL import source: %v", err)
}
defer source.Close()
_, readErr := io.ReadAll(source)
var limitErr *SQLImportSourceLimitError
if !errors.As(readErr, &limitErr) || limitErr.Kind != SQLImportSourceCompressionRatio {
t.Fatalf("read error = %v, want default compression-ratio protection", readErr)
}
}
func TestOpenSQLImportSourceReportsDecodedLimitBeforeRatioWhenBothCross(t *testing.T) {
path := filepath.Join(t.TempDir(), "dump.sql.gz")
writeGzipSQL(t, path, []byte(strings.Repeat("A", 4096)))
source, err := OpenSQLImportSource(path, SQLImportSourceOptions{
MaxDecodedBytes: 8,
MaxCompressionRatio: 0.01,
MinCompressedBytesForRatio: 1,
})
if err != nil {
t.Fatalf("open SQL import source: %v", err)
}
defer source.Close()
_, readErr := io.ReadAll(source)
var limitErr *SQLImportSourceLimitError
if !errors.As(readErr, &limitErr) || limitErr.Kind != SQLImportSourceDecodedByteLimit {
t.Fatalf("read error = %v, want earlier decoded-byte limit", readErr)
}
}
func writeGzipSQL(t *testing.T, path string, payload []byte) {
t.Helper()
file, err := os.Create(path)
if err != nil {
t.Fatalf("create gzip source: %v", err)
}
writer := gzip.NewWriter(file)
if _, err := writer.Write(payload); err != nil {
_ = file.Close()
t.Fatalf("write gzip source: %v", err)
}
if err := writer.Close(); err != nil {
_ = file.Close()
t.Fatalf("close gzip writer: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close gzip source: %v", err)
}
}
func encodeUTF16SQL(value string, order binary.ByteOrder) []byte {
encoded := utf16.Encode([]rune(value))
result := make([]byte, 2, 2+len(encoded)*2)
if order == binary.LittleEndian {
result[0], result[1] = 0xff, 0xfe
} else {
result[0], result[1] = 0xfe, 0xff
}
for _, codeUnit := range encoded {
result = append(result, 0, 0)
order.PutUint16(result[len(result)-2:], codeUnit)
}
return result
}