package test import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/update" "RenewCMS/internal/infrastructure/persistence/database" "RenewCMS/internal/infrastructure/persistence/models" infraUpdate "RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/useCases" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "os" "path/filepath" "runtime" "strings" "sync" "syscall" "testing" "time" ) const ( downloadTag = "v2.3.0-beta" downloadFeedURL = "https://git.example" downloadAssetPrefix = "https://git.example/dl/" boardTotalBytes = 27262976 boardDoneBytes = 11534336 ) var ( errDownloadDisk = errors.New("write failed: no space left on device") errDownloadNetwork = errors.New("connection reset by peer") errDownloadBackup = errors.New("vacuum into failed") errDownloadTooLarge = errors.New("the asset is larger than its size cap") errAssetStatusNotFound = errors.New("the asset server answered 404") errDownloadRunnerFail = errors.New("fork/exec: exec format error") errDownloadChmod = errors.New("operation not permitted") errDownloadStat = errors.New("stat failed") ) type fakeJobStore struct { mu sync.Mutex job update.Job found bool saveErr error history []string bytes []int64 } var _ update.JobStore = &fakeJobStore{} func (f *fakeJobStore) Load() (update.Job, bool) { f.mu.Lock() defer f.mu.Unlock() return f.job, f.found } func (f *fakeJobStore) Save(job update.Job) error { f.mu.Lock() defer f.mu.Unlock() if f.saveErr != nil { return f.saveErr } f.job = job f.found = true if job.State == update.StateDownloading { f.bytes = append(f.bytes, job.DownloadedBytes) } if len(f.history) == 0 || f.history[len(f.history)-1] != job.State { f.history = append(f.history, job.State) } return nil } func (f *fakeJobStore) put(job update.Job) { f.mu.Lock() defer f.mu.Unlock() f.job = job f.found = true } func (f *fakeJobStore) downloadedBytes() []int64 { f.mu.Lock() defer f.mu.Unlock() return append([]int64(nil), f.bytes...) } func (f *fakeJobStore) states() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.history...) } type fakeHelperDetector struct { installed bool } var _ update.HelperDetector = &fakeHelperDetector{} func (f *fakeHelperDetector) Installed() bool { return f.installed } type fakeUpdateLogger struct { mu sync.Mutex messages []string } var _ update.Logger = &fakeUpdateLogger{} func (f *fakeUpdateLogger) Printf(format string, args ...any) { f.mu.Lock() defer f.mu.Unlock() f.messages = append(f.messages, fmt.Sprintf(format, args...)) } func (f *fakeUpdateLogger) text() string { f.mu.Lock() defer f.mu.Unlock() return strings.Join(f.messages, "\n") } type fakeAssetFetcher struct { mu sync.Mutex files map[string][]byte errors map[string]error partial map[string]bool calls map[string]int during func(name string, report update.ProgressFunc) } var _ update.AssetFetcher = &fakeAssetFetcher{} func (f *fakeAssetFetcher) Fetch(asset update.Asset, destination string, report update.ProgressFunc) error { f.mu.Lock() f.calls[asset.Name]++ content := f.files[asset.Name] failure := f.errors[asset.Name] partial := f.partial[asset.Name] during := f.during f.mu.Unlock() if during != nil { during(asset.Name, report) } if failure != nil { if partial { _ = os.WriteFile(destination, content[:len(content)/2], 0o644) } return failure } report(int64(len(content)), asset.Size) return os.WriteFile(destination, content, 0o644) } func (f *fakeAssetFetcher) requested() []string { f.mu.Lock() defer f.mu.Unlock() names := make([]string, 0, len(f.calls)) for name := range f.calls { names = append(names, name) } return names } func (f *fakeAssetFetcher) callsFor(name string) int { f.mu.Lock() defer f.mu.Unlock() return f.calls[name] } func (f *fakeAssetFetcher) totalCalls() int { f.mu.Lock() defer f.mu.Unlock() total := 0 for _, count := range f.calls { total += count } return total } type fakeStagedRunner struct { mu sync.Mutex output string err error paths []string } var _ update.StagedBinaryRunner = &fakeStagedRunner{} func (f *fakeStagedRunner) Version(binaryPath string) (string, error) { f.mu.Lock() defer f.mu.Unlock() f.paths = append(f.paths, binaryPath) return f.output, f.err } type fakeDatabaseBackup struct { mu sync.Mutex err error calls int gate chan struct{} entered chan struct{} jobState func() string seen []string } var _ update.DatabaseBackup = &fakeDatabaseBackup{} func (f *fakeDatabaseBackup) Backup() (string, error) { f.mu.Lock() f.calls++ gate, entered := f.gate, f.entered if f.jobState != nil { f.seen = append(f.seen, f.jobState()) } err := f.err f.mu.Unlock() if entered != nil { close(entered) } if gate != nil { <-gate } return "", err } func (f *fakeDatabaseBackup) callCount() int { f.mu.Lock() defer f.mu.Unlock() return f.calls } type faultyStagingArea struct { *infraUpdate.OSStagingArea prepareErr error sizeErr error readErr error chmodErr error removeErr error removedTags []string mu sync.Mutex } var _ update.StagingArea = &faultyStagingArea{} func (f *faultyStagingArea) Prepare(tag string) error { if f.prepareErr != nil { return f.prepareErr } return f.OSStagingArea.Prepare(tag) } func (f *faultyStagingArea) Size(path string) (int64, error) { if f.sizeErr != nil { return 0, f.sizeErr } return f.OSStagingArea.Size(path) } func (f *faultyStagingArea) ReadChecksums(path string) (string, error) { if f.readErr != nil { return "", f.readErr } return f.OSStagingArea.ReadChecksums(path) } func (f *faultyStagingArea) MakeExecutable(path string) error { if f.chmodErr != nil { return f.chmodErr } return f.OSStagingArea.MakeExecutable(path) } func (f *faultyStagingArea) Remove(tag string) error { f.mu.Lock() f.removedTags = append(f.removedTags, tag) f.mu.Unlock() if f.removeErr != nil { return f.removeErr } return f.OSStagingArea.Remove(tag) } func (f *faultyStagingArea) removed() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.removedTags...) } type downloadRig struct { t *testing.T dataDir string updatesDir string platform update.Platform assetName string publicKey ed25519.PublicKey privateKey ed25519.PrivateKey store *fakeUpdateStore jobs *fakeJobStore helper *fakeHelperDetector results *fakeResultStore requests *fakeRestartRequestStore fetcher *fakeAssetFetcher runner *fakeStagedRunner backup *fakeDatabaseBackup staging *faultyStagingArea realBackup update.DatabaseBackup logger *fakeUpdateLogger recorder audit.Recorder start *useCases.StartUpdateDownloadUseCase status *useCases.GetUpdateStatusUseCase } func newDownloadRig(t *testing.T) *downloadRig { return newDownloadRigFor(t, update.Platform{OS: "linux", Arch: "amd64"}, []byte("renewcms test binary 2.3.0")) } func newDownloadRigFor(t *testing.T, platform update.Platform, binary []byte) *downloadRig { t.Helper() publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } dataDir := t.TempDir() rig := &downloadRig{ t: t, dataDir: dataDir, updatesDir: filepath.Join(dataDir, "updates"), platform: platform, assetName: update.AssetName(downloadTag, platform), publicKey: publicKey, privateKey: privateKey, store: &fakeUpdateStore{}, jobs: &fakeJobStore{}, helper: &fakeHelperDetector{installed: true}, results: &fakeResultStore{}, requests: &fakeRestartRequestStore{}, fetcher: &fakeAssetFetcher{ files: map[string][]byte{}, errors: map[string]error{}, partial: map[string]bool{}, calls: map[string]int{}, }, runner: &fakeStagedRunner{output: downloadTag}, backup: &fakeDatabaseBackup{}, logger: &fakeUpdateLogger{}, } rig.staging = &faultyStagingArea{OSStagingArea: infraUpdate.NewOSStagingArea(rig.updatesDir)} rig.backup.jobState = func() string { job, _ := rig.jobs.Load() return job.State } rig.fetcher.files[rig.assetName] = binary rig.signChecksums(rig.checksumsFor(rig.assetName, binary), privateKey) rig.publishAssets() rig.rebuild() return rig } func (r *downloadRig) rebuild() { verifier := infraUpdate.NewEd25519SignatureVerifier(r.publicKey) var backup update.DatabaseBackup = r.backup if r.realBackup != nil { backup = r.realBackup } r.start = useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{ Store: r.store, Jobs: r.jobs, Helper: r.helper, Results: r.results, Requests: r.requests, Fetcher: r.fetcher, Verifier: verifier, Runner: r.runner, Backup: backup, Staging: r.staging, Logger: r.logger, CurrentVersion: updateRunning, Platform: r.platform, FeedURL: downloadFeedURL, Now: func() time.Time { return updateTestCheckedAt }, Recorder: r.auditRecorder(), }) r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, r.results, r.requests, updateRunning, false, time.Now) } func (r *downloadRig) auditRecorder() audit.Recorder { if r.recorder == nil { return discardRecorder } return r.recorder } func (r *downloadRig) checksumsFor(name string, content []byte) string { sum := sha256.Sum256(content) return hex.EncodeToString(sum[:]) + " " + name + "\n" } func (r *downloadRig) signChecksums(checksums string, key ed25519.PrivateKey) { signature := base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(checksums))) + "\n" r.fetcher.files[update.ChecksumsAssetName] = []byte(checksums) r.fetcher.files[update.SignatureAssetName] = []byte(signature) } func (r *downloadRig) publishAssets() { result := newStoredUpdateResult() for name, content := range r.fetcher.files { result.Assets = append(result.Assets, update.Asset{Name: name, URL: downloadAssetPrefix + name, Size: int64(len(content))}) } r.store.result = result r.store.found = true } func (r *downloadRig) refreshAssetSizes() { for index, asset := range r.store.result.Assets { r.store.result.Assets[index].Size = int64(len(r.fetcher.files[asset.Name])) } } func (r *downloadRig) assetIndex(name string) int { for index, asset := range r.store.result.Assets { if asset.Name == name { return index } } r.t.Fatalf("asset %s is not published", name) return -1 } func (r *downloadRig) setAsset(name string, change func(*update.Asset)) { index := r.assetIndex(name) change(&r.store.result.Assets[index]) } func (r *downloadRig) dropAsset(name string) { index := r.assetIndex(name) assets := r.store.result.Assets r.store.result.Assets = append(append([]update.Asset(nil), assets[:index]...), assets[index+1:]...) } func (r *downloadRig) stagedDir() string { return filepath.Join(r.updatesDir, downloadTag) } func (r *downloadRig) waitForJob(states ...string) update.Job { r.t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if job, found := r.jobs.Load(); found { for _, state := range states { if job.State == state { return job } } } time.Sleep(2 * time.Millisecond) } job, _ := r.jobs.Load() r.t.Fatalf("job never reached %v, last record %+v, log: %s", states, job, r.logger.text()) return update.Job{} } func assertStagedDirGone(t *testing.T, rig *downloadRig) { t.Helper() if _, err := os.Stat(rig.stagedDir()); !os.IsNotExist(err) && !errors.Is(err, syscall.ENOTDIR) { t.Errorf("staged directory still exists: %v", err) } } func TestAssetNameFollowsThePlatform(t *testing.T) { cases := []struct { os, arch, asset string }{ {"linux", "amd64", "renewcms_v2.3.0-beta_linux_amd64"}, {"linux", "arm64", "renewcms_v2.3.0-beta_linux_arm64"}, {"darwin", "arm64", "renewcms_v2.3.0-beta_darwin_arm64"}, {"windows", "amd64", "renewcms_v2.3.0-beta_windows_amd64.exe"}, } for _, c := range cases { t.Run(c.os+"/"+c.arch, func(t *testing.T) { rig := newDownloadRigFor(t, update.Platform{OS: c.os, Arch: c.arch}, []byte("binary")) for _, other := range []string{"linux_amd64", "linux_arm64", "darwin_arm64", "windows_amd64.exe"} { name := "renewcms_" + downloadTag + "_" + other if _, listed := update.FindAsset(rig.store.result.Assets, name); !listed { rig.store.result.Assets = append(rig.store.result.Assets, update.Asset{Name: name, URL: downloadAssetPrefix + name, Size: 1}) } } if _, err := rig.start.Start(testCtx); err != nil { t.Fatal(err) } rig.waitForJob(update.StateReady, update.StateFailed) got := rig.fetcher.requested() want := map[string]bool{c.asset: true, "checksums.txt": true, "checksums.txt.sig": true} if len(got) != len(want) { t.Fatalf("requested %v, want %v", got, want) } for _, name := range got { if !want[name] { t.Errorf("unexpected asset requested: %s", name) } } }) } } func TestJobProgressIsAFlooredPercentOfBytes(t *testing.T) { job := update.Job{State: update.StateDownloading, DownloadedBytes: boardDoneBytes, TotalBytes: boardTotalBytes} progress := job.Progress() if progress == nil || progress.Percent != 42 || progress.DownloadedBytes != boardDoneBytes || progress.TotalBytes != boardTotalBytes { t.Errorf("progress = %+v", progress) } for _, state := range []string{update.StateVerifying, update.StateReady, update.StateFailed} { if (update.Job{State: state, DownloadedBytes: 5, TotalBytes: 10}).Progress() != nil { t.Errorf("progress must be nil in %s", state) } } if (update.Job{State: update.StateDownloading}).Progress() != nil { t.Error("progress needs a known total") } } func TestStatusUseCaseCanApplyOnlyForOwnersWithTheHelper(t *testing.T) { rig := newDownloadRig(t) if !rig.status.GetStatus(true).CanApply { t.Error("owner with helper must be able to apply") } if rig.status.GetStatus(false).CanApply { t.Error("admin must never be able to apply") } rig.helper.installed = false if rig.status.GetStatus(true).CanApply { t.Error("without the helper nobody can apply") } } func TestStartUpdateDownloadUseCaseHappyPath(t *testing.T) { rig := newDownloadRig(t) gate := make(chan struct{}) entered := make(chan struct{}) rig.backup.gate = gate rig.backup.entered = entered status, err := rig.start.Start(testCtx) if err != nil || status.State != update.StateDownloading || status.StartedAt == nil { t.Fatalf("status = %+v, err = %v", status, err) } if status.Progress == nil || status.Progress.TotalBytes != int64(len(rig.fetcher.files[rig.assetName])) { t.Errorf("progress = %+v", status.Progress) } <-entered if during := rig.status.GetStatus(true); during.State != update.StateVerifying { t.Errorf("state during the backup = %q, want verifying", during.State) } close(gate) job := rig.waitForJob(update.StateReady, update.StateFailed) if job.State != update.StateReady || job.Error != "" { t.Fatalf("job = %+v, log: %s", job, rig.logger.text()) } for _, name := range []string{rig.assetName, "checksums.txt", "checksums.txt.sig"} { if _, err := os.Stat(filepath.Join(rig.stagedDir(), name)); err != nil { t.Errorf("staged file %s: %v", name, err) } } if got := rig.backup.seen; len(got) != 1 || got[0] != update.StateVerifying { t.Errorf("job state seen by the backup = %v", got) } want := []string{update.StateDownloading, update.StateVerifying, update.StateReady} if got := rig.jobs.states(); strings.Join(got, ",") != strings.Join(want, ",") { t.Errorf("states = %v, want %v", got, want) } if ready := rig.status.GetStatus(true); ready.State != update.StateReady || ready.Progress != nil || ready.Error != nil || ready.Latest != downloadTag { t.Errorf("ready status = %+v", ready) } } func TestStartUpdateDownloadUseCaseGuards(t *testing.T) { t.Run("without the helper nothing starts", func(t *testing.T) { rig := newDownloadRig(t) rig.helper.installed = false if _, err := rig.start.Start(testCtx); !errors.Is(err, update.ErrNotPrepared) { t.Errorf("err = %v", err) } if rig.fetcher.totalCalls() != 0 { t.Error("the fetcher must not be called") } }) t.Run("the helper check comes before the availability check", func(t *testing.T) { rig := newDownloadRig(t) rig.helper.installed = false rig.store.found = false if _, err := rig.start.Start(testCtx); !errors.Is(err, update.ErrNotPrepared) { t.Errorf("err = %v", err) } }) t.Run("a stored job comes before the availability check", func(t *testing.T) { rig := newDownloadRig(t) rig.jobs.put(update.Job{State: update.StateReady}) rig.store.found = false if _, err := rig.start.Start(testCtx); !errors.Is(err, update.ErrJobInProgress) { t.Errorf("err = %v", err) } }) t.Run("nothing stored", func(t *testing.T) { rig := newDownloadRig(t) rig.store.found = false if _, err := rig.start.Start(testCtx); !errors.Is(err, update.ErrNothingToUpdate) { t.Errorf("err = %v", err) } }) t.Run("a job record that cannot be saved is an error", func(t *testing.T) { rig := newDownloadRig(t) rig.jobs.saveErr = errRepositoryFailure if _, err := rig.start.Start(testCtx); !errors.Is(err, errRepositoryFailure) { t.Errorf("err = %v", err) } if rig.fetcher.totalCalls() != 0 { t.Error("the fetcher must not be called") } }) } func TestTwoStartsAtOnceRunOneJob(t *testing.T) { rig := newDownloadRig(t) gate := make(chan struct{}) rig.fetcher.during = func(name string, _ update.ProgressFunc) { if name == rig.assetName { <-gate } } results := make(chan error, 2) var ready sync.WaitGroup ready.Add(2) for range 2 { go func() { ready.Done() ready.Wait() _, err := rig.start.Start(testCtx) results <- err }() } first, second := <-results, <-results close(gate) rig.waitForJob(update.StateReady, update.StateFailed) accepted := 0 for _, err := range []error{first, second} { if err == nil { accepted++ } else if !errors.Is(err, update.ErrJobInProgress) { t.Errorf("unexpected err %v", err) } } if accepted != 1 { t.Fatalf("accepted = %d, want 1", accepted) } for _, name := range []string{rig.assetName, "checksums.txt", "checksums.txt.sig"} { if rig.fetcher.callsFor(name) != 1 { t.Errorf("%s fetched %d times", name, rig.fetcher.callsFor(name)) } } } func TestJSONJobStorePersistence(t *testing.T) { startedAt := time.Date(2026, 9, 30, 10, 0, 0, 0, time.UTC) t.Run("round trip", func(t *testing.T) { path := filepath.Join(t.TempDir(), "update-job.json") job := update.Job{Tag: downloadTag, State: update.StateFailed, Error: update.ErrorKindVerification, StartedAt: startedAt} if err := infraUpdate.NewJSONJobStore(path).Save(job); err != nil { t.Fatal(err) } got, found := infraUpdate.NewJSONJobStore(path).Load() if !found || got.Tag != downloadTag || got.State != update.StateFailed || got.Error != "verification" || !got.StartedAt.Equal(startedAt) { t.Errorf("job = %+v found = %v", got, found) } }) t.Run("a ready job survives a restart while its binary exists", func(t *testing.T) { dir := t.TempDir() binary := filepath.Join(dir, "renewcms_staged") if err := os.WriteFile(binary, []byte("x"), 0o644); err != nil { t.Fatal(err) } path := filepath.Join(dir, "update-job.json") _ = infraUpdate.NewJSONJobStore(path).Save(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: startedAt, StagedBinary: binary}) status := newUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true) if status.State != update.StateReady { t.Errorf("state = %q", status.State) } }) t.Run("a downloading record at startup reads as failed download", func(t *testing.T) { for _, state := range []string{update.StateDownloading, update.StateVerifying} { path := filepath.Join(t.TempDir(), "update-job.json") _ = infraUpdate.NewJSONJobStore(path).Save(update.Job{Tag: downloadTag, State: state, StartedAt: startedAt, DownloadedBytes: 4, TotalBytes: 9}) status := newUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true) if status.State != update.StateFailed || status.Error == nil || *status.Error != "download" || status.Progress != nil { t.Errorf("%s: status = %+v", state, status) } } }) t.Run("a ready record whose binary is gone reads as idle", func(t *testing.T) { path := filepath.Join(t.TempDir(), "update-job.json") _ = infraUpdate.NewJSONJobStore(path).Save(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: startedAt, StagedBinary: filepath.Join(t.TempDir(), "missing")}) status := newUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true) if status.State != update.StateIdle || status.StartedAt != nil { t.Errorf("status = %+v", status) } }) t.Run("a missing or corrupt file reads as idle", func(t *testing.T) { missing := filepath.Join(t.TempDir(), "update-job.json") if _, found := infraUpdate.NewJSONJobStore(missing).Load(); found { t.Error("a missing file must read as no job") } corrupt := filepath.Join(t.TempDir(), "update-job.json") if err := os.WriteFile(corrupt, []byte("{not json"), 0o600); err != nil { t.Fatal(err) } if _, found := infraUpdate.NewJSONJobStore(corrupt).Load(); found { t.Error("a corrupt file must read as no job") } }) } func TestFileHelperDetector(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "update-helper.json") detector := infraUpdate.NewFileHelperDetector(path) if detector.Installed() { t.Error("an absent status file means no helper") } if err := os.WriteFile(path, []byte("not json"), 0o600); err != nil { t.Fatal(err) } if detector.Installed() { t.Error("a corrupt status file means no helper") } if err := os.WriteFile(path, []byte(`{"version":"1"}`), 0o600); err != nil { t.Fatal(err) } if !detector.Installed() { t.Error("a valid status file means the helper is installed") } } func TestSQLiteDatabaseBackup(t *testing.T) { db := newBackupTestDatabase(t) backups := filepath.Join(t.TempDir(), "backups") clock := time.Date(2026, 9, 30, 10, 0, 0, 0, time.UTC) backup := database.NewSQLiteBackup(db, backups, func() time.Time { clock = clock.Add(time.Second) return clock }) t.Run("the copy holds the same rows", func(t *testing.T) { path, err := backup.Backup() if err != nil { t.Fatal(err) } if got := countBackupRows(t, path); got != 2 { t.Errorf("rows in the copy = %d, want 2", got) } }) t.Run("only the three newest copies remain", func(t *testing.T) { existing, _ := filepath.Glob(filepath.Join(backups, "*.db")) for _, path := range existing { backdate(t, path, 10*time.Hour) } var newest []string for range 4 { path, err := backup.Backup() if err != nil { t.Fatal(err) } newest = append(newest, path) for index, earlier := range newest[:len(newest)-1] { backdate(t, earlier, time.Duration(len(newest)-1-index)*time.Hour) } } entries, err := os.ReadDir(backups) if err != nil || len(entries) != 3 { t.Fatalf("entries = %d, err = %v", len(entries), err) } for _, path := range newest[1:] { if _, err := os.Stat(path); err != nil { t.Errorf("a newer copy was pruned: %v", err) } } }) t.Run("a failed copy prunes nothing", func(t *testing.T) { directory := t.TempDir() fixed := func() time.Time { return clock } failing := database.NewSQLiteBackup(db, directory, fixed) if _, err := failing.Backup(); err != nil { t.Fatal(err) } for _, name := range []string{"a.db", "b.db", "c.db"} { if err := os.WriteFile(filepath.Join(directory, name), []byte("old"), 0o600); err != nil { t.Fatal(err) } } if _, err := failing.Backup(); err == nil { t.Fatal("copying onto an existing file must fail") } entries, _ := os.ReadDir(directory) if len(entries) != 4 { t.Errorf("entries = %d, want the 4 earlier files untouched", len(entries)) } }) } func newBackupTestDatabase(t *testing.T) *gorm.DB { t.Helper() path := filepath.Join(t.TempDir(), "live.db") db, err := gorm.Open(sqlite.Open(database.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard}) if err != nil { t.Fatal(err) } t.Cleanup(func() { if sqlDB, err := db.DB(); err == nil { _ = sqlDB.Close() } }) if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}); err != nil { t.Fatal(err) } for _, title := range []string{"First article", "Second article"} { if err := db.Create(&models.Article{Title: title}).Error; err != nil { t.Fatal(err) } } return db } func countBackupRows(t *testing.T, path string) int64 { t.Helper() copyDb, err := gorm.Open(sqlite.Open(path+"?mode=ro"), &gorm.Config{Logger: logger.Discard}) if err != nil { t.Fatal(err) } defer func() { if sqlDB, err := copyDb.DB(); err == nil { _ = sqlDB.Close() } }() var count int64 if err := copyDb.Model(&models.Article{}).Count(&count).Error; err != nil { t.Fatal(err) } return count } func backdate(t *testing.T, path string, age time.Duration) { t.Helper() modified := time.Now().Add(-age) if err := os.Chtimes(path, modified, modified); err != nil { if os.IsNotExist(err) { return } t.Fatal(err) } } func TestSQLiteDatabaseBackupStaleCopyHeldOpen(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("an open file can only block deletion on windows") } db := newBackupTestDatabase(t) directory := t.TempDir() locked := filepath.Join(directory, "locked.db") for _, name := range []string{"locked.db", "b.db", "c.db"} { if err := os.WriteFile(filepath.Join(directory, name), []byte("old"), 0o600); err != nil { t.Fatal(err) } } backdate(t, locked, 10*time.Hour) held, err := os.Open(locked) if err != nil { t.Fatal(err) } defer held.Close() backup := database.NewSQLiteBackup(db, directory, time.Now) if _, err := backup.Backup(); err == nil { t.Error("a stale copy that cannot be removed must surface as an error") } } func TestStartUpdateDownloadUseCaseProgressCadence(t *testing.T) { rig := newDownloadRigFor(t, update.Platform{OS: "linux", Arch: "amd64"}, []byte(strings.Repeat("b", 1000))) rig.fetcher.during = func(name string, report update.ProgressFunc) { if name != rig.assetName { return } for _, done := range []int64{10, 15, 999, 1000} { report(done, 1000) } } if _, err := rig.start.Start(testCtx); err != nil { t.Fatal(err) } rig.waitForJob(update.StateReady, update.StateFailed) want := []int64{0, 10, 999, 1000, 1000} got := rig.jobs.downloadedBytes() if fmt.Sprint(got) != fmt.Sprint(want) { t.Errorf("saved download progress = %v, want %v", got, want) } } func TestStartUpdateDownloadUseCaseAcceptsAnUndeclaredSize(t *testing.T) { rig := newDownloadRig(t) rig.setAsset(rig.assetName, func(asset *update.Asset) { asset.Size = 0 }) if _, err := rig.start.Start(testCtx); err != nil { t.Fatal(err) } if job := rig.waitForJob(update.StateReady, update.StateFailed); job.State != update.StateReady { t.Errorf("job = %+v, log: %s", job, rig.logger.text()) } } func TestStartUpdateDownloadUseCaseReportsAFailedRecordSave(t *testing.T) { const saveFailure = "update job record could not be saved" t.Run("a record that cannot be saved is logged", func(t *testing.T) { rig := newDownloadRig(t) rig.fetcher.errors[rig.assetName] = errDownloadNetwork rig.fetcher.during = func(name string, _ update.ProgressFunc) { if name == rig.assetName { rig.jobs.mu.Lock() rig.jobs.saveErr = errRepositoryFailure rig.jobs.mu.Unlock() } } if _, err := rig.start.Start(testCtx); err != nil { t.Fatal(err) } deadline := time.Now().Add(5 * time.Second) for !strings.Contains(rig.logger.text(), saveFailure) && time.Now().Before(deadline) { time.Sleep(2 * time.Millisecond) } if !strings.Contains(rig.logger.text(), saveFailure) { t.Errorf("log = %s", rig.logger.text()) } }) t.Run("a saved failure is not reported as a save error", func(t *testing.T) { rig := newDownloadRig(t) rig.fetcher.errors[rig.assetName] = errDownloadNetwork if _, err := rig.start.Start(testCtx); err != nil { t.Fatal(err) } rig.waitForJob(update.StateFailed) if strings.Contains(rig.logger.text(), saveFailure) { t.Errorf("log = %s", rig.logger.text()) } }) } func TestSQLiteDatabaseBackupKeepsTheNewestNameOnEqualTimes(t *testing.T) { directory := t.TempDir() moment := time.Now().Add(-time.Hour) for _, name := range []string{"renewcms-1.db", "renewcms-2.db", "renewcms-3.db", "renewcms-4.db"} { path := filepath.Join(directory, name) if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { t.Fatal(err) } if err := os.Chtimes(path, moment, moment); err != nil { t.Fatal(err) } } backup := database.NewSQLiteBackup(newBackupTestDatabase(t), directory, time.Now) if _, err := backup.Backup(); err != nil { t.Fatal(err) } for name, kept := range map[string]bool{"renewcms-1.db": false, "renewcms-2.db": false, "renewcms-3.db": true, "renewcms-4.db": true} { _, err := os.Stat(filepath.Join(directory, name)) if kept != (err == nil) { t.Errorf("%s kept = %v, want %v", name, err == nil, kept) } } }