Files
RenewCMS/test/update_download_test.go
T

1017 lines
29 KiB
Go

package test
import (
"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"
"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
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 },
})
r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, r.results, r.requests, updateRunning, false, time.Now)
}
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) {
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(); 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()
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(); !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(); !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(); !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(); !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(); !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()
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{}); 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(); 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(); 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(); 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(); 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)
}
}
}