package test import ( "RenewCMS/api" "RenewCMS/api/middleware" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/update" "RenewCMS/internal/infrastructure/persistence/database" 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), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetUpdateStatusUseCase: rig.status, CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder), StartUpdateDownloadUseCase: rig.start, } return &updateHarness{router: api.NewSystemRouter(container, nil), 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 = database.NewSQLiteBackup(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(testCtx); 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") } }) }