84b185be39
Phase A - Stability: - Engine.Start(): recovers orphaned jobs (running/queued/waking_up) after crash - Engine.Stop(): graceful shutdown - cancels all in-flight jobs and waits - Queue keyed by jobID (not syncPairID): cancel now targets exact job - Local rsync uses jobCtx (context.Background() replaced) - Migrations wrapped in transactions; checksums stored Phase B - Security: - admin/admin default removed: SYNCSERVER_ADMIN_PASSWORD required on first run - Path validation: rejects .., leading -, null bytes in sync pair paths - Rsync flags allowlist: dangerous flags blocked (--rsync-path, -e, --files-from) - Shell concat in RunRemote replaced with proper sh -c escaping - knownhosts: replaced custom parser with golang.org/x/crypto/ssh/knownhosts - RequireAdmin wired: machine CRUD, SSH key ops, settings require admin role - deploy-keys: uses authorized_keys only (no private key upload) - Hardcoded /var/lib/syncserver/ssh paths replaced with cfg.SSHDir() Phase C - Operational: - /readyz health check: DB query + SSH dir accessibility - /metrics endpoint: Prometheus text format (jobs, queue, machines) - Event struct JSON tags: job_id, machine_id, type (snake_case) - EventBus broadcast: fanned out to all subscribers - SQLite VACUUM INTO backup: scheduled before cleanup if BackupDir set - Filesystem job log cleanup: removes .log files for purged jobs - Backup retention: old backups auto-purged Phase D - Frontend: - Schedules page: REST API + full CRUD UI for cron schedules - Dashboard: cancel button for running/queued jobs - JobDetail: server-side log download via API - Settings: displays data_dir from server - 404 page: proper NotFound component Phase E - Tests: - auth_test.go: JWT, bcrypt, middleware, seed (18 tests) - models_test.go: Job, Machine, SyncPair, Schedule repos (18 tests) - go test -race: no data races found
313 lines
8.4 KiB
Go
313 lines
8.4 KiB
Go
package syncengine
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type remoteCmdTest struct {
|
|
srcHost string
|
|
srcUser string
|
|
srcPath string
|
|
dstHost string
|
|
dstUser string
|
|
dstPath string
|
|
direction string
|
|
}
|
|
|
|
func (tc remoteCmdTest) build() (srcArg, dstArg string) {
|
|
runner := NewRsyncRunner("/tmp/ssh", "")
|
|
args := runner.buildArgs(&SyncPairConfig{
|
|
Source: tc.srcPath,
|
|
Dest: tc.dstPath,
|
|
Direction: tc.direction,
|
|
})
|
|
sourcePath := args[len(args)-2]
|
|
destPath := args[len(args)-1]
|
|
return sourcePath, destPath
|
|
}
|
|
|
|
func TestBuildArgs_Push(t *testing.T) {
|
|
runner := NewRsyncRunner("/tmp/ssh", "")
|
|
pair := &SyncPairConfig{
|
|
Source: "/local/src",
|
|
Dest: "admin@10.5.0.144:/remote/dst",
|
|
Direction: "push",
|
|
RsyncFlags: "-aP",
|
|
ExcludePatterns: []string{},
|
|
}
|
|
args := runner.buildArgs(pair)
|
|
if args[0] != "-aP" {
|
|
t.Errorf("first flag = %q, want %q", args[0], "-aP")
|
|
}
|
|
if args[len(args)-2] != "/local/src/" {
|
|
t.Errorf("source = %q, want %q (auto-appended trailing slash)", args[len(args)-2], "/local/src/")
|
|
}
|
|
if args[len(args)-1] != "admin@10.5.0.144:/remote/dst" {
|
|
t.Errorf("dest = %q, want %q", args[len(args)-1], "admin@10.5.0.144:/remote/dst")
|
|
}
|
|
}
|
|
|
|
func TestBuildArgs_Pull(t *testing.T) {
|
|
runner := NewRsyncRunner("/tmp/ssh", "")
|
|
pair := &SyncPairConfig{
|
|
Source: "admin@10.5.0.144:/remote/src",
|
|
Dest: "/local/dst",
|
|
Direction: "pull",
|
|
RsyncFlags: "-aP",
|
|
ExcludePatterns: []string{},
|
|
}
|
|
args := runner.buildArgs(pair)
|
|
if args[len(args)-2] != "/local/dst" {
|
|
t.Errorf("pull: second-to-last (dest) = %q, want %q", args[len(args)-2], "/local/dst")
|
|
}
|
|
if args[len(args)-1] != "admin@10.5.0.144:/remote/src/" {
|
|
t.Errorf("pull: last (source) = %q, want %q (auto-appended trailing slash)", args[len(args)-1], "admin@10.5.0.144:/remote/src/")
|
|
}
|
|
}
|
|
|
|
func TestBuildArgs_Mirror(t *testing.T) {
|
|
runner := NewRsyncRunner("/tmp/ssh", "")
|
|
pair := &SyncPairConfig{
|
|
Source: "/local/src",
|
|
Dest: "admin@10.5.0.144:/remote/dst",
|
|
Direction: "mirror",
|
|
RsyncFlags: "-aP",
|
|
ExcludePatterns: []string{},
|
|
}
|
|
args := runner.buildArgs(pair)
|
|
foundDelete := false
|
|
for _, a := range args {
|
|
if a == "--delete" {
|
|
foundDelete = true
|
|
break
|
|
}
|
|
}
|
|
if !foundDelete {
|
|
t.Errorf("mirror args = %v, want --delete present", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildArgs_MultiTokenFlags(t *testing.T) {
|
|
runner := NewRsyncRunner("/tmp/ssh", "")
|
|
pair := &SyncPairConfig{
|
|
Source: "/local/src",
|
|
Dest: "admin@10.5.0.144:/remote/dst",
|
|
Direction: "push",
|
|
RsyncFlags: "-aP --partial",
|
|
ExcludePatterns: []string{},
|
|
}
|
|
args := runner.buildArgs(pair)
|
|
if args[0] != "-aP" {
|
|
t.Errorf("first flag = %q, want %q", args[0], "-aP")
|
|
}
|
|
if args[1] != "--partial" {
|
|
t.Errorf("second flag = %q, want %q", args[1], "--partial")
|
|
}
|
|
}
|
|
|
|
func TestBuildArgs_ExcludePatterns(t *testing.T) {
|
|
runner := NewRsyncRunner("/tmp/ssh", "")
|
|
pair := &SyncPairConfig{
|
|
Source: "/local/src",
|
|
Dest: "admin@10.5.0.144:/remote/dst",
|
|
Direction: "push",
|
|
RsyncFlags: "-aP",
|
|
ExcludePatterns: []string{"*.tmp", ".DS_Store"},
|
|
}
|
|
args := runner.buildArgs(pair)
|
|
var excludes []string
|
|
for _, a := range args {
|
|
if strings.HasPrefix(a, "--exclude=") {
|
|
excludes = append(excludes, a)
|
|
}
|
|
}
|
|
if len(excludes) != 2 {
|
|
t.Errorf("excludes = %v, want 2 exclude entries", excludes)
|
|
}
|
|
if excludes[0] != "--exclude=*.tmp" {
|
|
t.Errorf("exclude[0] = %q, want %q", excludes[0], "--exclude=*.tmp")
|
|
}
|
|
}
|
|
|
|
func TestRunRemote_PushDestNoDoublePrefix(t *testing.T) {
|
|
tc := remoteCmdTest{
|
|
srcHost: "10.5.1.10",
|
|
srcUser: "root",
|
|
srcPath: "/share/homes/admin/media",
|
|
dstHost: "10.5.0.144",
|
|
dstUser: "admin",
|
|
dstPath: "admin@10.5.0.144:/share/media/peliculas",
|
|
direction: "push",
|
|
}
|
|
_, dstArg := tc.build()
|
|
|
|
if countOccurrences(dstArg, "@") > 1 {
|
|
t.Errorf("push dest %q has double SSH spec", dstArg)
|
|
}
|
|
if dstArg != "admin@10.5.0.144:/share/media/peliculas" {
|
|
t.Errorf("push dest = %q, want 'admin@10.5.0.144:/share/media/peliculas'", dstArg)
|
|
}
|
|
}
|
|
|
|
func TestRunRemote_PushSrcAndDestStayAsIs(t *testing.T) {
|
|
tc := remoteCmdTest{
|
|
srcHost: "10.5.1.10",
|
|
srcUser: "root",
|
|
srcPath: "/share/homes/admin/media",
|
|
dstHost: "10.5.0.144",
|
|
dstUser: "admin",
|
|
dstPath: "/share/media/peliculas",
|
|
direction: "push",
|
|
}
|
|
srcArg, dstArg := tc.build()
|
|
|
|
if srcArg != "/share/homes/admin/media/" {
|
|
t.Errorf("push src = %q, want '/share/homes/admin/media/' (auto-appended trailing slash, no user@host: prefix added)", srcArg)
|
|
}
|
|
if dstArg != "/share/media/peliculas" {
|
|
t.Errorf("push dst = %q, want raw path '/share/media/peliculas'", dstArg)
|
|
}
|
|
}
|
|
|
|
func TestRunRemote_PullSrcAndDestStayAsIs(t *testing.T) {
|
|
tc := remoteCmdTest{
|
|
srcHost: "10.5.0.144",
|
|
srcUser: "admin",
|
|
srcPath: "admin@10.5.0.144:/share/media/peliculas",
|
|
dstHost: "10.5.1.10",
|
|
dstUser: "root",
|
|
dstPath: "/share/data",
|
|
direction: "pull",
|
|
}
|
|
srcArg, dstArg := tc.build()
|
|
|
|
if countOccurrences(srcArg, "@") > 1 {
|
|
t.Errorf("pull src %q has double SSH spec", srcArg)
|
|
}
|
|
if srcArg != "/share/data" {
|
|
t.Errorf("pull src (rsync dest) = %q, want raw '/share/data'", srcArg)
|
|
}
|
|
if !strings.HasPrefix(dstArg, "admin@10.5.0.144:/") {
|
|
t.Errorf("pull dst (rsync src) = %q, want 'admin@10.5.0.144:/...' prefix", dstArg)
|
|
}
|
|
}
|
|
|
|
func TestBuildArgs_AutoAppendsTrailingSlashToSource(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
source string
|
|
dest string
|
|
direction string
|
|
wantSrc string
|
|
}{
|
|
{
|
|
name: "push, source without trailing slash",
|
|
source: "/mnt/storage/multimedia/series",
|
|
dest: "/share/media/series",
|
|
direction: "push",
|
|
wantSrc: "/mnt/storage/multimedia/series/",
|
|
},
|
|
{
|
|
name: "push, source already has trailing slash (idempotent)",
|
|
source: "/mnt/storage/multimedia/series/",
|
|
dest: "/share/media/series",
|
|
direction: "push",
|
|
wantSrc: "/mnt/storage/multimedia/series/",
|
|
},
|
|
{
|
|
name: "push, remote source without trailing slash",
|
|
source: "admin@baby-nas:/mnt/storage/multimedia/series",
|
|
dest: "/share/media/series",
|
|
direction: "push",
|
|
wantSrc: "admin@baby-nas:/mnt/storage/multimedia/series/",
|
|
},
|
|
{
|
|
name: "pull, source without trailing slash",
|
|
source: "admin@baby-nas:/mnt/storage/multimedia/series",
|
|
dest: "/share/media/series",
|
|
direction: "pull",
|
|
wantSrc: "admin@baby-nas:/mnt/storage/multimedia/series/",
|
|
},
|
|
{
|
|
name: "mirror, source without trailing slash",
|
|
source: "/mnt/storage/multimedia/series",
|
|
dest: "admin@10.5.0.144:/share/media/series",
|
|
direction: "mirror",
|
|
wantSrc: "/mnt/storage/multimedia/series/",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
runner := NewRsyncRunner("/tmp/ssh", "")
|
|
pair := &SyncPairConfig{
|
|
Source: tc.source,
|
|
Dest: tc.dest,
|
|
Direction: tc.direction,
|
|
}
|
|
args := runner.buildArgs(pair)
|
|
|
|
var gotSrc, gotDest string
|
|
if tc.direction == "pull" {
|
|
gotDest = args[len(args)-2]
|
|
gotSrc = args[len(args)-1]
|
|
} else {
|
|
gotSrc = args[len(args)-2]
|
|
gotDest = args[len(args)-1]
|
|
}
|
|
|
|
if gotSrc != tc.wantSrc {
|
|
t.Errorf("source = %q, want %q (dest must never be touched)", gotSrc, tc.wantSrc)
|
|
}
|
|
if gotDest != tc.dest {
|
|
t.Errorf("dest = %q, want %q (dest must never be normalized)", gotDest, tc.dest)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func countOccurrences(s, substr string) int {
|
|
return strings.Count(s, substr)
|
|
}
|
|
|
|
func TestRun_FlagsPreservedWithPrivKey(t *testing.T) {
|
|
runner := NewRsyncRunner("/tmp/ssh", "/tmp/ssh/id_ed25519")
|
|
pair := &SyncPairConfig{
|
|
Source: "/local/src",
|
|
Dest: "admin@10.5.0.144:/remote/dst",
|
|
Direction: "push",
|
|
RsyncFlags: "-aP --partial",
|
|
ExcludePatterns: []string{},
|
|
}
|
|
|
|
cmd := runner.buildRsyncCmd(context.Background(), pair)
|
|
|
|
if cmd.Args[0] != "rsync" {
|
|
t.Errorf("cmd.Args[0] = %q, want 'rsync'", cmd.Args[0])
|
|
}
|
|
if cmd.Args[1] != "-e" {
|
|
t.Errorf("cmd.Args[1] = %q, want '-e' (the -e flag for ssh)", cmd.Args[1])
|
|
}
|
|
if !strings.Contains(cmd.Args[2], "ssh -i") {
|
|
t.Errorf("cmd.Args[2] = %q, want ssh -i ...", cmd.Args[2])
|
|
}
|
|
hasAP := false
|
|
hasPartial := false
|
|
for i, a := range cmd.Args {
|
|
if a == "-aP" && i > 2 {
|
|
hasAP = true
|
|
}
|
|
if a == "--partial" && i > 2 {
|
|
hasPartial = true
|
|
}
|
|
}
|
|
if !hasAP {
|
|
t.Errorf("cmd.Args = %v, want -aP flag preserved (not dropped)", cmd.Args)
|
|
}
|
|
if !hasPartial {
|
|
t.Errorf("cmd.Args = %v, want --partial flag present", cmd.Args)
|
|
}
|
|
}
|