Files
RenewCMS/test/update_download_http_test.go
T

705 lines
25 KiB
Go

package test
import (
"RenewCMS/api"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
)
const downloadPath = "/update/download"
func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
t.Helper()
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
users := newUpdateUserRepo()
feed := &fakeReleaseFeed{}
now := func() time.Time { return updateTestCheckedAt }
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetUpdateStatusUseCase: rig.status,
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now),
StartUpdateDownloadUseCase: rig.start,
}
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: rig.store}
}
func waitForHTTPState(t *testing.T, h *updateHarness, states ...string) map[string]any {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var body map[string]any
for time.Now().Before(deadline) {
body = decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
for _, state := range states {
if body["state"] == state {
return body
}
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("state never reached %v, last body %v", states, body)
return nil
}
type pausedDownload struct {
reached chan struct{}
release chan struct{}
}
func pauseBinaryAt(rig *downloadRig, done int64, total int64) *pausedDownload {
pause := &pausedDownload{reached: make(chan struct{}), release: make(chan struct{})}
var once sync.Once
rig.fetcher.during = func(name string, report update.ProgressFunc) {
if name != rig.assetName {
return
}
once.Do(func() {
report(done, total)
close(pause.reached)
})
<-pause.release
}
return pause
}
func newBoardSizedRig(t *testing.T) *downloadRig {
t.Helper()
return newDownloadRigFor(t, update.Platform{OS: "linux", Arch: "amd64"}, make([]byte, boardTotalBytes))
}
func TestStartUpdateDownloadHTTP(t *testing.T) {
t.Run("Start the update, happy path", func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
gate := make(chan struct{})
rig.fetcher.during = func(name string, _ update.ProgressFunc) {
if name == rig.assetName {
<-gate
}
}
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
}
body := decodeUpdateBody(t, rec)
assertUpdateKeys(t, body)
if body["state"] != "downloading" || body["started_at"] == nil || body["error"] != nil || body["can_apply"] != true {
t.Errorf("body = %v", body)
}
during := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
progress, _ := during["progress"].(map[string]any)
if during["state"] != "downloading" || progress == nil || progress["total_bytes"] != float64(len(rig.fetcher.files[rig.assetName])) {
t.Errorf("during = %v", during)
}
close(gate)
ready := waitForHTTPState(t, h, "ready", "failed")
if ready["state"] != "ready" || ready["error"] != nil || ready["progress"] != nil || ready["latest"] != downloadTag {
t.Fatalf("ready = %v, log: %s", ready, rig.logger.text())
}
for _, name := range []string{rig.assetName, "checksums.txt", "checksums.txt.sig"} {
if _, err := os.Stat(filepath.Join(rig.updatesDir, downloadTag, name)); err != nil {
t.Errorf("staged %s: %v", name, err)
}
}
if rig.backup.callCount() != 1 || rig.backup.seen[0] != update.StateVerifying {
t.Errorf("backup calls = %d, state seen = %v", rig.backup.callCount(), rig.backup.seen)
}
if _, err := os.Stat(filepath.Join(rig.dataDir, "renewcms.db")); err == nil {
t.Error("the database file must not be touched")
}
})
t.Run("Progress is reported in bytes", func(t *testing.T) {
rig := newBoardSizedRig(t)
h := newDownloadHTTP(t, rig)
pause := pauseBinaryAt(rig, boardDoneBytes, boardTotalBytes)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
<-pause.reached
body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateAdminId))
close(pause.release)
waitForHTTPState(t, h, "ready", "failed")
progress, _ := body["progress"].(map[string]any)
if body["state"] != "downloading" || progress == nil || progress["percent"] != float64(42) || progress["downloaded_bytes"] != float64(boardDoneBytes) || progress["total_bytes"] != float64(boardTotalBytes) {
t.Errorf("body = %v", body)
}
})
t.Run("Every owner sees the same job", func(t *testing.T) {
rig := newBoardSizedRig(t)
h := newDownloadHTTP(t, rig)
pause := pauseBinaryAt(rig, boardDoneBytes, boardTotalBytes)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
<-pause.reached
owner := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateSecondOwner))
admin := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateAdminId))
close(pause.release)
waitForHTTPState(t, h, "ready", "failed")
if owner["state"] != "downloading" || admin["state"] != "downloading" || owner["started_at"] != admin["started_at"] || owner["started_at"] == nil {
t.Errorf("owner = %v admin = %v", owner, admin)
}
ownerProgress, _ := owner["progress"].(map[string]any)
adminProgress, _ := admin["progress"].(map[string]any)
if ownerProgress == nil || adminProgress == nil || ownerProgress["percent"] != adminProgress["percent"] || ownerProgress["downloaded_bytes"] != adminProgress["downloaded_bytes"] {
t.Errorf("progress differs: %v vs %v", ownerProgress, adminProgress)
}
if owner["can_apply"] != true || admin["can_apply"] != false {
t.Errorf("can_apply owner = %v admin = %v", owner["can_apply"], admin["can_apply"])
}
})
t.Run("Only the owner", func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateAdminId)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start and the fetcher must not be called")
}
})
t.Run("POST is guarded like the other system routes", func(t *testing.T) {
cases := []struct {
name string
userId uint32
tokenVersion uint32
status int
}{
{"no jwt cookie", 0, 0, http.StatusUnauthorized},
{"a stale token version", updateOwnerId, 1, http.StatusUnauthorized},
{"an unverified owner", updateUnverifiedOwner, 0, http.StatusForbidden},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
rec := h.send(t, http.MethodPost, downloadPath, c.userId, c.tokenVersion)
if rec.Code != c.status {
t.Errorf("status = %d, want %d", rec.Code, c.status)
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start")
}
})
}
})
t.Run("Nothing to update", func(t *testing.T) {
t.Run("available false", func(t *testing.T) {
rig := newDownloadRig(t)
rig.store.result.Latest = updateRunning
h := newDownloadHTTP(t, rig)
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId), http.StatusBadRequest)
})
t.Run("no stored result", func(t *testing.T) {
rig := newDownloadRig(t)
rig.store.found = false
h := newDownloadHTTP(t, rig)
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId), http.StatusBadRequest)
})
})
t.Run("The server is not prepared", func(t *testing.T) {
rig := newDownloadRig(t)
rig.helper.installed = false
h := newDownloadHTTP(t, rig)
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId), http.StatusConflict)
if body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId)); body["can_apply"] != false {
t.Errorf("can_apply = %v", body["can_apply"])
}
rig.store.result.Latest = updateRunning
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateSecondOwner), http.StatusConflict)
})
t.Run("A second start while a job exists", func(t *testing.T) {
for _, state := range []string{update.StateDownloading, update.StateVerifying, update.StateReady} {
t.Run(state, func(t *testing.T) {
rig := newDownloadRig(t)
existing := update.Job{Tag: downloadTag, State: state, StartedAt: time.Date(2026, 9, 30, 9, 0, 0, 0, time.UTC)}
rig.jobs.put(existing)
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if rec.Code != http.StatusConflict {
t.Errorf("status = %d", rec.Code)
}
if job, _ := rig.jobs.Load(); job != existing || rig.fetcher.totalCalls() != 0 {
t.Errorf("the existing job was touched: %+v", job)
}
})
}
})
t.Run("Two starts at once run one job", func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
gate := make(chan struct{})
rig.fetcher.during = func(name string, _ update.ProgressFunc) {
if name == rig.assetName {
<-gate
}
}
codes := make(chan int, 2)
var ready sync.WaitGroup
ready.Add(2)
for _, owner := range []uint32{updateOwnerId, updateSecondOwner} {
go func() {
ready.Done()
ready.Wait()
codes <- h.sendAs(t, http.MethodPost, downloadPath, owner).Code
}()
}
first, second := <-codes, <-codes
close(gate)
waitForHTTPState(t, h, "ready", "failed")
accepted, conflicts := 0, 0
for _, code := range []int{first, second} {
switch code {
case http.StatusAccepted:
accepted++
case http.StatusConflict:
conflicts++
}
}
if accepted != 1 || conflicts != 1 {
t.Errorf("codes = %d and %d", first, second)
}
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))
}
}
})
t.Run("Try again after a failure", func(t *testing.T) {
rig := newDownloadRig(t)
failedAt := time.Date(2026, 9, 29, 8, 0, 0, 0, time.UTC)
rig.jobs.put(update.Job{Tag: downloadTag, State: update.StateFailed, Error: update.ErrorKindDownload, StartedAt: failedAt})
if err := os.MkdirAll(rig.stagedDir(), 0o755); err != nil {
t.Fatal(err)
}
stale := filepath.Join(rig.stagedDir(), "left-over")
if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
body := decodeUpdateBody(t, rec)
if rec.Code != http.StatusAccepted || body["error"] != nil || body["started_at"] == "2026-09-29T08:00:00Z" || body["state"] != "downloading" {
t.Fatalf("status = %d body = %v", rec.Code, body)
}
waitForHTTPState(t, h, "ready", "failed")
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Error("the old staged files must be replaced")
}
})
t.Run("Start is rate limited per user", func(t *testing.T) {
rig := newDownloadRig(t)
rig.helper.installed = false
h := newDownloadHTTP(t, rig)
authLimited := middleware.NewRateLimiter(5, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
for attempt := 1; attempt <= 5; attempt++ {
if rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId); rec.Code != http.StatusConflict {
t.Fatalf("attempt %d status = %d", attempt, rec.Code)
}
}
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
retryAfter, err := strconv.Atoi(rec.Header().Get("Retry-After"))
if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || err != nil || retryAfter < 1 {
t.Errorf("status = %d body = %q retry-after = %q", rec.Code, rec.Body.String(), rec.Header().Get("Retry-After"))
}
if other := h.sendAs(t, http.MethodPost, downloadPath, updateSecondOwner); other.Code != http.StatusConflict {
t.Errorf("another owner status = %d", other.Code)
}
for attempt := 1; attempt <= 5; attempt++ {
if check := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId); check.Code != http.StatusOK {
t.Errorf("check limiter was consumed: attempt %d status = %d", attempt, check.Code)
}
}
for attempt := 1; attempt <= 5; attempt++ {
recorder := httptest.NewRecorder()
authLimited.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/login", nil))
if recorder.Code != http.StatusOK {
t.Errorf("auth limiter attempt %d status = %d", attempt, recorder.Code)
}
}
})
}
func assertNothingStarted(t *testing.T, rig *downloadRig, rec *httptest.ResponseRecorder, status int) {
t.Helper()
if rec.Code != status {
t.Errorf("status = %d, want %d", rec.Code, status)
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start and the fetcher must not be called")
}
}
func TestUpdatePreflightRefusalsHTTP(t *testing.T) {
conditions := []struct {
name string
apply func(rig *downloadRig)
}{
{"no asset is named for the host platform", func(rig *downloadRig) { rig.dropAsset(rig.assetName) }},
{"checksums.txt is missing from the assets", func(rig *downloadRig) { rig.dropAsset("checksums.txt") }},
{"checksums.txt.sig is missing from the assets", func(rig *downloadRig) { rig.dropAsset("checksums.txt.sig") }},
{"an asset URL is http, not https", func(rig *downloadRig) {
rig.setAsset(rig.assetName, func(asset *update.Asset) { asset.URL = "http://git.example/dl/" + asset.Name })
}},
{"an asset URL is on a host other than UPDATE_FEED_URL's host", func(rig *downloadRig) {
rig.setAsset("checksums.txt", func(asset *update.Asset) { asset.URL = "https://evil.example/dl/" + asset.Name })
}},
}
for _, condition := range conditions {
t.Run(condition.name, func(t *testing.T) {
rig := newDownloadRig(t)
condition.apply(rig)
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != apperr.UpdateRefusedErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
for _, leak := range []string{"git.example", "evil.example", "renewcms_", "checksums"} {
if strings.Contains(rec.Body.String(), leak) {
t.Errorf("body leaks %q", leak)
}
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start and the fetcher must not be called")
}
if !strings.Contains(rig.logger.text(), "refused") {
t.Errorf("the reason must be logged, log: %q", rig.logger.text())
}
})
}
}
type jobFault struct {
name string
apply func(rig *downloadRig)
logged string
}
func runFaultTable(t *testing.T, wantError string, faults []jobFault) {
t.Helper()
for _, fault := range faults {
t.Run(fault.name, func(t *testing.T) {
rig := newDownloadRig(t)
fault.apply(rig)
h := newDownloadHTTP(t, rig)
post := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if post.Code != http.StatusAccepted {
t.Fatalf("status = %d: %s", post.Code, post.Body.String())
}
rec := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
body := waitForHTTPState(t, h, "failed", "ready")
if body["state"] != "failed" || body["error"] != wantError || body["progress"] != nil {
t.Fatalf("body = %v, log: %s", body, rig.logger.text())
}
assertStagedDirGone(t, rig)
if !strings.Contains(rig.logger.text(), fault.logged) {
t.Errorf("log %q must contain %q", rig.logger.text(), fault.logged)
}
for _, response := range []string{post.Body.String(), rec.Body.String(), h.sendAs(t, http.MethodGet, "/update", updateOwnerId).Body.String()} {
if strings.Contains(response, fault.logged) {
t.Errorf("a response leaks %q", fault.logged)
}
}
for _, state := range rig.jobs.states() {
if state == update.StateReady {
t.Error("a failed job must never have been ready")
}
}
})
}
}
func TestUpdateVerificationFailuresHTTP(t *testing.T) {
runFaultTable(t, "verification", []jobFault{
{"the binary was changed after signing (SHA-256 mismatch)", func(rig *downloadRig) {
tampered := append([]byte(nil), rig.fetcher.files[rig.assetName]...)
tampered[0] ^= 0xff
rig.fetcher.files[rig.assetName] = tampered
}, "sha-256 mismatch"},
{"checksums.txt was changed after signing", func(rig *downloadRig) {
tampered := []byte(strings.Replace(string(rig.fetcher.files["checksums.txt"]), "renewcms_", "renewcmz_", 1))
rig.fetcher.files["checksums.txt"] = tampered
}, "signature"},
{"the signature is from a different key", func(rig *downloadRig) {
_, other, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
rig.signChecksums(string(rig.fetcher.files["checksums.txt"]), other)
}, "does not match"},
{"checksums.txt has no line for the asset", func(rig *downloadRig) {
rig.signChecksums(rig.checksumsFor("renewcms_other_file", []byte("x")), rig.privateKey)
rig.refreshAssetSizes()
}, "has no line"},
{"the staged binary prints v2.2.9-beta for --version", func(rig *downloadRig) {
rig.runner.output = "v2.2.9-beta"
}, "v2.2.9-beta"},
{"the staged checksums cannot be read back", func(rig *downloadRig) {
rig.staging.readErr = errDownloadDisk
}, "no space left on device"},
{"the staged binary cannot be made executable", func(rig *downloadRig) {
rig.staging.chmodErr = errDownloadChmod
}, "operation not permitted"},
{"the staged binary cannot be run", func(rig *downloadRig) {
rig.runner.err = errDownloadRunnerFail
}, "exec format error"},
})
}
func TestUpdateDownloadFailuresHTTP(t *testing.T) {
runFaultTable(t, "download", []jobFault{
{"the connection drops after half the bytes (truncated download)", func(rig *downloadRig) {
rig.fetcher.errors[rig.assetName] = errDownloadNetwork
rig.fetcher.partial[rig.assetName] = true
}, "connection reset by peer"},
{"the bytes received differ from the size the release declares", func(rig *downloadRig) {
rig.setAsset(rig.assetName, func(asset *update.Asset) { asset.Size += 7 })
}, "the release declares"},
{"a file exceeds its size cap", func(rig *downloadRig) {
rig.fetcher.errors["checksums.txt"] = errDownloadTooLarge
}, "size cap"},
{"the download passes the overall timeout", func(rig *downloadRig) {
rig.fetcher.errors[rig.assetName] = context.DeadlineExceeded
}, "deadline exceeded"},
{"the feed host answers 404 or 500 for an asset", func(rig *downloadRig) {
rig.fetcher.errors["checksums.txt.sig"] = errAssetStatusNotFound
}, "answered 404"},
{"the updates directory cannot be created or written (disk error)", func(rig *downloadRig) {
if err := os.WriteFile(rig.updatesDir, []byte("a file where the directory should be"), 0o644); err != nil {
t.Fatal(err)
}
}, "updates"},
{"the staging directory cannot be prepared (injected)", func(rig *downloadRig) {
rig.staging.prepareErr = errDownloadDisk
}, "no space left on device"},
{"a staged file cannot be measured", func(rig *downloadRig) {
rig.staging.sizeErr = errDownloadStat
}, "stat failed"},
{"the disk fills during the write", func(rig *downloadRig) {
rig.fetcher.errors[rig.assetName] = errDownloadDisk
rig.fetcher.partial[rig.assetName] = true
}, "no space left on device"},
{"the database backup fails", func(rig *downloadRig) {
rig.backup.err = errDownloadBackup
}, "vacuum into failed"},
})
}
func TestUpdateBackupBeforeReadyHTTP(t *testing.T) {
t.Run("The backup comes before ready, and the last 3 are kept", func(t *testing.T) {
rig := newDownloadRig(t)
backups := filepath.Join(rig.dataDir, "backups")
if err := os.MkdirAll(backups, 0o755); err != nil {
t.Fatal(err)
}
for index, name := range []string{"earlier-1.db", "earlier-2.db", "earlier-3.db"} {
path := filepath.Join(backups, name)
if err := os.WriteFile(path, []byte("older copy"), 0o600); err != nil {
t.Fatal(err)
}
backdate(t, path, time.Duration(10-index)*time.Hour)
}
rig.realBackup = infraUpdate.NewSQLiteDatabaseBackup(newBackupTestDatabase(t), backups, time.Now)
rig.rebuild()
h := newDownloadHTTP(t, rig)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
body := waitForHTTPState(t, h, "ready", "failed")
if body["state"] != "ready" {
t.Fatalf("body = %v, log: %s", body, rig.logger.text())
}
entries, err := os.ReadDir(backups)
if err != nil || len(entries) != 3 {
t.Fatalf("backups = %d, err = %v", len(entries), err)
}
if _, err := os.Stat(filepath.Join(backups, "earlier-1.db")); !os.IsNotExist(err) {
t.Error("the oldest backup must be deleted")
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "renewcms-") {
if rows := countBackupRows(t, filepath.Join(backups, entry.Name())); rows != 2 {
t.Errorf("rows in the new copy = %d, want 2", rows)
}
}
}
})
t.Run("a failed backup leaves state failed and does not prune", func(t *testing.T) {
rig := newDownloadRig(t)
rig.backup.err = errDownloadBackup
h := newDownloadHTTP(t, rig)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
body := waitForHTTPState(t, h, "ready", "failed")
if body["state"] != "failed" || body["error"] != "download" {
t.Errorf("body = %v", body)
}
})
}
func TestFailedUpdateJobRemovesItsStagedDirectoryEvenWhenRemovalFails(t *testing.T) {
rig := newDownloadRig(t)
rig.fetcher.errors[rig.assetName] = errDownloadNetwork
rig.staging.removeErr = errDownloadDisk
if _, err := rig.start.Start(); err != nil {
t.Fatal(err)
}
job := rig.waitForJob(update.StateFailed)
if job.Error != update.ErrorKindDownload || job.StagedBinary != "" {
t.Errorf("job = %+v", job)
}
if got := rig.staging.removed(); len(got) != 1 || got[0] != downloadTag {
t.Errorf("removed tags = %v", got)
}
}
func TestOSStagingArea(t *testing.T) {
root := filepath.Join(t.TempDir(), "updates")
area := infraUpdate.NewOSStagingArea(root)
t.Run("paths are rooted under the tag", func(t *testing.T) {
if got, want := area.PathFor("v1", "a.bin"), filepath.Join(root, "v1", "a.bin"); got != want {
t.Errorf("path = %q, want %q", got, want)
}
})
t.Run("prepare empties and recreates the directory", func(t *testing.T) {
if err := area.Prepare("v1"); err != nil {
t.Fatal(err)
}
stale := area.PathFor("v1", "stale")
if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if err := area.Prepare("v1"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Errorf("stale file survived: %v", err)
}
if info, err := os.Stat(filepath.Join(root, "v1")); err != nil || !info.IsDir() {
t.Errorf("directory missing: %v", err)
}
})
t.Run("size and checksums read the staged file", func(t *testing.T) {
path := area.PathFor("v1", "checksums.txt")
if err := os.WriteFile(path, []byte("abc"), 0o644); err != nil {
t.Fatal(err)
}
if size, err := area.Size(path); err != nil || size != 3 {
t.Errorf("size = %d, err = %v", size, err)
}
if content, err := area.ReadChecksums(path); err != nil || content != "abc" {
t.Errorf("content = %q, err = %v", content, err)
}
missing := area.PathFor("v1", "missing")
if _, err := area.Size(missing); err == nil {
t.Error("size of a missing file must fail")
}
if _, err := area.ReadChecksums(missing); err == nil {
t.Error("reading a missing file must fail")
}
})
t.Run("make executable sets the mode and fails on a missing file", func(t *testing.T) {
path := area.PathFor("v1", "binary")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := area.MakeExecutable(path); err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o755 {
t.Errorf("mode = %v, err = %v", info.Mode(), err)
}
}
if err := area.MakeExecutable(area.PathFor("v1", "missing")); err == nil {
t.Error("a missing file must fail")
}
})
t.Run("remove deletes the tag directory only", func(t *testing.T) {
if err := area.Prepare("v2"); err != nil {
t.Fatal(err)
}
if err := area.Remove("v1"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "v1")); !os.IsNotExist(err) {
t.Errorf("v1 still exists: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "v2")); err != nil {
t.Errorf("v2 must remain: %v", err)
}
})
t.Run("prepare fails when a file sits where the root should be", func(t *testing.T) {
blocked := filepath.Join(t.TempDir(), "updates")
if err := os.WriteFile(blocked, []byte("file"), 0o644); err != nil {
t.Fatal(err)
}
if err := infraUpdate.NewOSStagingArea(blocked).Prepare("v1"); err == nil {
t.Error("prepare must fail")
}
})
}