backupy-agent/apps/agent/internal/pipeline/sqlite_test.go
TronoSfera 3cfac4daca fix(sqlite): stage snapshot in temp file instead of /dev/stdout
The Alpine sqlite3 binary refuses to open /dev/stdout when running as
a non-root uid in a container ('Error: cannot open "/dev/stdout"'),
which breaks every backup attempt. Switch the dump path to stage the
snapshot in a temp file, then stream that file through gzip into the
pipeline. Adds streamSideEffect to the test mockRunner so the existing
gzip-wrap test can simulate the sqlite3 process writing to its
destination path.
2026-05-18 14:46:48 +03:00

192 lines
6 KiB
Go

package pipeline
import (
"bytes"
"compress/gzip"
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
backupv1 "github.com/backupy/backupy/packages/proto/gen/go/backupv1"
)
func TestSqlite_Name(t *testing.T) {
t.Parallel()
require.Equal(t, "sqlite", (&sqliteDriver{}).Name())
}
func TestSqlite_LooksLikeVersion(t *testing.T) {
t.Parallel()
require.True(t, looksLikeSqliteVersion("3.45.1 2024-01-30 16:01:20"))
require.True(t, looksLikeSqliteVersion("3.42.0"))
require.False(t, looksLikeSqliteVersion("totally bogus"))
require.False(t, looksLikeSqliteVersion(""))
}
func TestSqlite_Validate_MissingDatabasePath(t *testing.T) {
t.Parallel()
d := &sqliteDriver{
binary: "sqlite3",
runner: &mockRunner{outputResp: map[string][]byte{"--version": []byte("3.45.1\n")}},
statFn: os.Stat,
}
err := d.Validate(context.Background(), &backupv1.Target{Type: backupv1.DbType_SQLITE, Connection: &backupv1.ConnectionConfig{}})
require.Error(t, err)
require.Contains(t, err.Error(), "database must be the path")
}
func TestSqlite_Validate_FileNotFound(t *testing.T) {
t.Parallel()
d := &sqliteDriver{
binary: "sqlite3",
runner: &mockRunner{outputResp: map[string][]byte{"--version": []byte("3.45.1\n")}},
statFn: os.Stat,
}
err := d.Validate(context.Background(), &backupv1.Target{
Type: backupv1.DbType_SQLITE,
Connection: &backupv1.ConnectionConfig{Database: "/path/does/not/exist.db"},
})
require.Error(t, err)
require.Contains(t, err.Error(), "cannot stat database")
}
func TestSqlite_Validate_BinaryMissing(t *testing.T) {
t.Parallel()
d := &sqliteDriver{
binary: "sqlite3",
runner: &errOutputRunner{err: errors.New("not found")},
statFn: os.Stat,
}
err := d.Validate(context.Background(), &backupv1.Target{
Type: backupv1.DbType_SQLITE,
Connection: &backupv1.ConnectionConfig{Database: "/tmp/foo.db"},
})
require.Error(t, err)
require.Contains(t, err.Error(), "version probe failed")
}
func TestSqlite_Validate_OK(t *testing.T) {
t.Parallel()
tmp := writeTempDB(t, []byte("placeholder"))
d := &sqliteDriver{
binary: "sqlite3",
runner: &mockRunner{outputResp: map[string][]byte{"--version": []byte("3.45.1 2024-01-30\n")}},
statFn: os.Stat,
}
err := d.Validate(context.Background(), &backupv1.Target{
Type: backupv1.DbType_SQLITE,
Connection: &backupv1.ConnectionConfig{Database: tmp},
})
require.NoError(t, err)
}
func TestSqlite_Dump_WrapsOutputInGzip(t *testing.T) {
t.Parallel()
tmp := writeTempDB(t, []byte("placeholder"))
payload := bytes.Repeat([]byte{0x53, 0x51, 0x4c}, 16) // pretend SQLite header bytes
mock := &mockRunner{
outputResp: map[string][]byte{"--version": []byte("3.45.1 2024-01-30\n")},
// The new Dump implementation stages the snapshot in a temp
// file, then re-reads it into the gzip pipe. Simulate the
// real sqlite3 process by parsing the destination path out
// of the .backup dot-command and writing the canned payload
// to that path.
streamSideEffect: func(args []string) error {
if len(args) < 2 {
return errors.New("expected at least 2 args")
}
dot := args[1]
const prefix = ".backup '"
if !strings.HasPrefix(dot, prefix) || !strings.HasSuffix(dot, "'") {
return errors.New("malformed .backup dot-command")
}
path := dot[len(prefix) : len(dot)-1]
return os.WriteFile(path, payload, 0o600)
},
}
d := &sqliteDriver{binary: "sqlite3", runner: mock, statFn: os.Stat}
var buf bytes.Buffer
info, err := d.Dump(context.Background(), &backupv1.Target{
Type: backupv1.DbType_SQLITE,
Connection: &backupv1.ConnectionConfig{Database: tmp},
}, &buf)
require.NoError(t, err)
require.Equal(t, "SQLite 3.45.1", info.EngineVersion)
require.True(t, IsSqliteGzMagic(buf.Bytes()))
gz, err := gzip.NewReader(&buf)
require.NoError(t, err)
defer gz.Close()
got, err := io.ReadAll(gz)
require.NoError(t, err)
require.Equal(t, payload, got)
// Confirm the dump invoked `.backup '<tmpfile>'`.
require.NotEmpty(t, mock.calls)
streamCall := mock.calls[0]
require.Equal(t, tmp, streamCall.Args[0])
require.True(t, strings.HasPrefix(streamCall.Args[1], ".backup '"))
require.True(t, strings.HasSuffix(streamCall.Args[1], "'"))
}
func TestSqlite_Dump_MissingPath(t *testing.T) {
t.Parallel()
d := &sqliteDriver{binary: "sqlite3", runner: &mockRunner{}, statFn: os.Stat}
var buf bytes.Buffer
_, err := d.Dump(context.Background(), &backupv1.Target{
Type: backupv1.DbType_SQLITE,
Connection: &backupv1.ConnectionConfig{},
}, &buf)
require.Error(t, err)
require.Contains(t, err.Error(), "must be the path")
}
func TestSqlite_Dump_StreamErrorWraps(t *testing.T) {
t.Parallel()
tmp := writeTempDB(t, []byte("placeholder"))
mock := &mockRunner{
outputResp: map[string][]byte{"--version": []byte("3.45.1\n")},
streamErr: errors.New("permission denied"),
}
d := &sqliteDriver{binary: "sqlite3", runner: mock, statFn: os.Stat}
var buf bytes.Buffer
_, err := d.Dump(context.Background(), &backupv1.Target{
Type: backupv1.DbType_SQLITE,
Connection: &backupv1.ConnectionConfig{Database: tmp},
}, &buf)
require.Error(t, err)
require.Contains(t, err.Error(), ".backup exec")
}
func TestIsSqliteGzMagic(t *testing.T) {
t.Parallel()
require.True(t, IsSqliteGzMagic([]byte{0x1f, 0x8b, 0x08}))
require.False(t, IsSqliteGzMagic([]byte{0x00, 0x00}))
}
func TestSqlite_ProbeMetadata_NoDriver(t *testing.T) {
t.Parallel()
// In the MVP build no sqlite database/sql driver is registered.
// Confirm probeSqliteMetadata returns the expected sentinel error so
// callers can degrade gracefully.
_, _, err := probeSqliteMetadata(context.Background(), "/tmp/anything.db")
require.Error(t, err)
require.Contains(t, err.Error(), "no sqlite driver registered")
}
// writeTempDB drops a tiny placeholder file into a temp directory and
// returns its absolute path.
func writeTempDB(t *testing.T, contents []byte) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "test.db")
require.NoError(t, os.WriteFile(p, contents, 0o600))
return p
}