mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-13 16:34:35 +08:00
- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出 - 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口 - 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局 - 补充多语言、健康状态、数据目录迁移和回归测试
157 lines
5.0 KiB
Go
157 lines
5.0 KiB
Go
package sqlaudit
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestBuildExportIgnoresPaginationAndFiltersAllMatches(t *testing.T) {
|
|
store := openTestStore(t)
|
|
now := time.Now().UnixMilli()
|
|
for index := 0; index < 3; index++ {
|
|
event := sampleEvent("export-"+string(rune('a'+index)), now+int64(index))
|
|
event.ConnectionID = "export-connection"
|
|
if err := store.Append(event); err != nil {
|
|
t.Fatalf("Append returned error: %v", err)
|
|
}
|
|
}
|
|
other := sampleEvent("other", now+10)
|
|
other.ConnectionID = "other-connection"
|
|
if err := store.Append(other); err != nil {
|
|
t.Fatalf("Append other returned error: %v", err)
|
|
}
|
|
|
|
payload, err := store.BuildExport(Filter{
|
|
ConnectionID: "export-connection",
|
|
Page: 3,
|
|
PageSize: 1,
|
|
}, "json")
|
|
if err != nil {
|
|
t.Fatalf("BuildExport(json) returned error: %v", err)
|
|
}
|
|
var events []Event
|
|
if err := json.Unmarshal(payload, &events); err != nil {
|
|
t.Fatalf("decode JSON export: %v\npayload=%s", err, payload)
|
|
}
|
|
if len(events) != 3 {
|
|
t.Fatalf("export should ignore pagination and include 3 filtered events, got %d", len(events))
|
|
}
|
|
for _, event := range events {
|
|
if event.ConnectionID != "export-connection" {
|
|
t.Fatalf("export ignored non-pagination filter: %#v", event)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildCSVExportPreventsFormulaInjection(t *testing.T) {
|
|
store := openTestStore(t)
|
|
event := sampleEvent("csv-formula", time.Now().UnixMilli())
|
|
event.ConnectionID = "=HYPERLINK(\"https://example.test\")"
|
|
event.Database = " @SUM(1+1)"
|
|
if err := store.Append(event); err != nil {
|
|
t.Fatalf("Append returned error: %v", err)
|
|
}
|
|
payload, err := store.BuildExport(Filter{}, "CSV")
|
|
if err != nil {
|
|
t.Fatalf("BuildExport(csv) returned error: %v", err)
|
|
}
|
|
reader := csv.NewReader(bytes.NewReader(payload))
|
|
records, err := reader.ReadAll()
|
|
if err != nil {
|
|
t.Fatalf("read CSV export: %v\npayload=%s", err, payload)
|
|
}
|
|
if len(records) != 2 {
|
|
t.Fatalf("CSV export rows=%d, want header + event", len(records))
|
|
}
|
|
connectionColumn := csvHeaderIndex(t, records[0], "connectionId")
|
|
databaseColumn := csvHeaderIndex(t, records[0], "database")
|
|
if !strings.HasPrefix(records[1][connectionColumn], "'=") {
|
|
t.Fatalf("connection ID formula was not escaped: %q", records[1][connectionColumn])
|
|
}
|
|
if !strings.HasPrefix(records[1][databaseColumn], "'@") {
|
|
t.Fatalf("whitespace-prefixed formula was not escaped: %q", records[1][databaseColumn])
|
|
}
|
|
}
|
|
|
|
func TestBuildExportRejectsUnsupportedFormat(t *testing.T) {
|
|
store := openTestStore(t)
|
|
_, err := store.BuildExport(Filter{}, "xlsx")
|
|
if !errors.Is(err, ErrUnsupportedExportFormat) {
|
|
t.Fatalf("BuildExport error=%v, want ErrUnsupportedExportFormat", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildExportReturnsRecognizableRecordLimitError(t *testing.T) {
|
|
store := openTestStore(t)
|
|
now := time.Now().UnixMilli()
|
|
if err := store.Append(sampleEvent("limit-a", now)); err != nil {
|
|
t.Fatalf("Append first returned error: %v", err)
|
|
}
|
|
if err := store.Append(sampleEvent("limit-b", now+1)); err != nil {
|
|
t.Fatalf("Append second returned error: %v", err)
|
|
}
|
|
for _, format := range []string{"json", "csv"} {
|
|
if _, err := store.buildExportWithLimits(Filter{}, format, 1, maxExportBytes); !errors.Is(err, ErrExportRecordLimit) {
|
|
t.Fatalf("%s export error=%v, want ErrExportRecordLimit", format, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildExportReturnsRecognizableSizeLimitError(t *testing.T) {
|
|
store := openTestStore(t)
|
|
event := sampleEvent("large-export", time.Now().UnixMilli())
|
|
event.Database = strings.Repeat("database", 30)
|
|
if err := store.Append(event); err != nil {
|
|
t.Fatalf("Append returned error: %v", err)
|
|
}
|
|
for _, format := range []string{"json", "csv"} {
|
|
if _, err := store.buildExportWithLimits(Filter{}, format, maxExportRecords, 64); !errors.Is(err, ErrExportSizeLimit) {
|
|
t.Fatalf("%s export error=%v, want ErrExportSizeLimit", format, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadExportEventsClosesRowsBeforeReturning(t *testing.T) {
|
|
store := openTestStore(t)
|
|
now := time.Now().UnixMilli()
|
|
if err := store.Append(sampleEvent("export-connection-release", now)); err != nil {
|
|
t.Fatalf("Append returned error: %v", err)
|
|
}
|
|
events, err := store.loadExportEvents(normalizeFilter(Filter{}), maxExportRecords, maxExportBytes)
|
|
if err != nil || len(events) != 1 {
|
|
t.Fatalf("loadExportEvents events=%#v err=%v", events, err)
|
|
}
|
|
if inUse := store.db.Stats().InUse; inUse != 0 {
|
|
t.Fatalf("loadExportEvents returned before releasing SQLite rows: inUse=%d", inUse)
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- store.Append(sampleEvent("append-after-export-load", now+1))
|
|
}()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("Append after export load returned error: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("export row cursor retained the store's only SQLite connection")
|
|
}
|
|
}
|
|
|
|
func csvHeaderIndex(t *testing.T, header []string, name string) int {
|
|
t.Helper()
|
|
for index, value := range header {
|
|
if value == name {
|
|
return index
|
|
}
|
|
}
|
|
t.Fatalf("CSV header %q missing from %#v", name, header)
|
|
return -1
|
|
}
|