1011 lines
28 KiB
Go
1011 lines
28 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api"
|
|
"RenewCMS/internal/domain/update"
|
|
"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
|
|
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},
|
|
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,
|
|
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, updateRunning)
|
|
}
|
|
|
|
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 := useCases.NewGetUpdateStatusUseCase(&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 := useCases.NewGetUpdateStatusUseCase(&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 := useCases.NewGetUpdateStatusUseCase(&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 := infraUpdate.NewSQLiteDatabaseBackup(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 := infraUpdate.NewSQLiteDatabaseBackup(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(api.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 := infraUpdate.NewSQLiteDatabaseBackup(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 := infraUpdate.NewSQLiteDatabaseBackup(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)
|
|
}
|
|
}
|
|
}
|