Files
ratatoskr-go/internal/update/update_test.go
Hermes 63668a0f69
All checks were successful
CI / test (push) Successful in 59s
CI / build-and-package (amd64, darwin) (push) Successful in 42s
CI / build-and-package (amd64, linux) (push) Successful in 40s
CI / build-and-package (amd64, windows) (push) Successful in 42s
CI / build-and-package (arm64, darwin) (push) Successful in 45s
CI / build-and-package (arm64, linux) (push) Successful in 41s
fix(update): убрать псевдо-версию latest, всё в commit-<sha7>
Gitea: при параллельных PUT матрицы (6 платформ) псевдо-версия 'latest'
переуказывается на каждый снимок, companion-файлы и бинарь разъезжаются
между версиями — checksum U4 ломался.

- CI: публикует бинарь+.version+.sha256 в  (commit-<sha7>), latest не используется
- Updater.ResolveLatest: новейшая commit-* версия из листинга /api/v1/packages/{owner}/generic/{package},
  берём макс id у которой есть бинарь текущей платформы
- Download/Verify/checksum читают ТУ ЖЕ version, а не latest
2026-08-16 13:05:37 +05:00

252 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package update
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// mockGitea поднимает httptest-сервер: листинг версий `/api/v1/packages/...`
// и файлы конкретных версий `/api/packages/.../{version}/{filename}`.
// version — имя новейшей версии (id бóльший), У которой есть бинарь+метаданные;
// старые версии бинаря не содержат и отсеются ResolveLatest.
func mockGitea(t *testing.T, bin []byte, version, checksum string) *httptest.Server {
t.Helper()
name := PlatformFilename()
// старый commit, чтобы проверить, что Resolve берёт именно новейший.
old := "commit-old" + version[len("commit-"):]
fileSets := map[string]map[string][]byte{
version: {
name: bin,
name + ".version": []byte(version),
name + ".sha256": []byte(checksum),
},
}
versions := []map[string]any{
{"id": 1, "version": old},
{"id": 2, "version": version},
}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/packages/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/generic/ratatoskr") {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(versions)
})
mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
// путь: /api/packages/{owner}/generic/{pkg}/{version}/{filename}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 6 {
http.NotFound(w, r)
return
}
ver := parts[len(parts)-2]
fileName := parts[len(parts)-1]
body, ok := fileSets[ver][fileName]
if !ok {
http.NotFound(w, r)
return
}
_, _ = w.Write(body)
})
return httptest.NewServer(mux)
}
// testUpdater строит Updater с базой на мок-сервере и каталогом tmp.
func testUpdater(t *testing.T, bin []byte, version, cur string) (*Updater, string) {
t.Helper()
sum := sha256hex(bin)
srv := mockGitea(t, bin, version, sum)
t.Cleanup(srv.Close)
dir := t.TempDir()
u := &Updater{
BaseURL: srv.URL,
Owner: "kamelion",
Package: "ratatoskr",
CurrentVersion: cur,
Dir: dir,
}
return u, dir
}
func TestFilename(t *testing.T) {
cases := []struct{ goos, goarch, want string }{
{"linux", "amd64", "ratatoskr-linux-amd64"},
{"linux", "arm64", "ratatoskr-linux-arm64"},
{"darwin", "arm64", "ratatoskr-darwin-arm64"},
{"windows", "amd64", "ratatoskr-windows-amd64.exe"},
}
for _, c := range cases {
if got := Filename(c.goos, c.goarch); got != c.want {
t.Errorf("Filename(%s,%s)=%q want %q", c.goos, c.goarch, got, c.want)
}
}
}
func TestCheck_UpdateAvailable(t *testing.T) {
u, _ := testUpdater(t, []byte("bin"), "commit-abc1234", "commit-old0000")
res := u.Check(context.Background())
if res.Err != nil {
t.Fatalf("Check err = %v", res.Err)
}
if !res.UpdateAvailable {
t.Fatal("expected update available")
}
if res.Version != "commit-abc1234" {
t.Errorf("Version = %q want commit-abc1234", res.Version)
}
}
func TestCheck_AlreadyCurrent(t *testing.T) {
u, _ := testUpdater(t, []byte("bin"), "commit-abc1234", "commit-abc1234")
res := u.Check(context.Background())
if res.Err != nil {
t.Fatalf("Check err = %v", res.Err)
}
if res.UpdateAvailable {
t.Fatal("not expected update (same version)")
}
}
func TestCheck_ServerDown(t *testing.T) {
dir := t.TempDir()
u := &Updater{BaseURL: "http://127.0.0.1:1", Owner: "k", Package: "p", CurrentVersion: "v1", Dir: dir}
res := u.Check(context.Background())
if res.Err == nil {
t.Fatal("expected error for unreachable server")
}
// ошибка должна классифицироваться как U1 ErrCheckFailed
var uerr *Error
if !errorsAs(res.Err, &uerr) || uerr.Code != U1 {
t.Errorf("expected U1 error, got %v", res.Err)
}
}
func TestDownload_And_Verify_Good(t *testing.T) {
bin := []byte("ratatoskr-binary-content-v2")
u, dir := testUpdater(t, bin, "commit-new12345", "")
file, err := u.Download(context.Background(), "commit-new12345")
if err != nil {
t.Fatalf("Download err = %v", err)
}
if !strings.Contains(file, ".new") {
t.Errorf("temp file %q should end with .new", filepath.Base(file))
}
if err := u.Verify(context.Background(), "commit-new12345", file); err != nil {
t.Fatalf("Verify err = %v", err)
}
// .new лежит в каталоге
entries, _ := os.ReadDir(dir)
if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".new") {
t.Errorf("expected 1 .new file in dir, got %v", entries)
}
}
func TestVerify_ChecksumMismatch(t *testing.T) {
bin := []byte("A")
srv := mockGitea(t, bin, "commit-x", sha256hex([]byte("differente-content")))
t.Cleanup(srv.Close)
u := &Updater{BaseURL: srv.URL, Owner: "k", Package: "p", Dir: t.TempDir()}
file := filepath.Join(u.Dir, ".ratatoskr.commit-x.new")
if err := os.WriteFile(file, []byte("A"), 0o755); err != nil {
t.Fatal(err)
}
err := u.Verify(context.Background(), "commit-x", file)
if err == nil {
t.Fatal("expected checksum mismatch error")
}
var uerr *Error
if !errorsAs(err, &uerr) || uerr.Code != U4 {
t.Errorf("expected U4 error, got %v", err)
}
}
func TestSanitizeVer(t *testing.T) {
cases := map[string]string{
"commit-abc1234": "commit-abc1234",
"v1.2.3": "v1.2.3",
"a/b c": "a_b_c",
"": "unknown",
}
for in, want := range cases {
if got := sanitizeVer(in); got != want {
t.Errorf("sanitizeVer(%q)=%q want %q", in, got, want)
}
}
}
func TestApplySwap_NoPending(t *testing.T) {
dir := t.TempDir()
u := &Updater{Dir: dir}
if err := u.applySwap(dir, filepath.Join(dir, "ratatoskr")); err != nil {
t.Fatalf("applySwap with no .new should return nil, got %v", err)
}
}
func TestApplySwap_Applies(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "ratatoskr")
if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
t.Fatal(err)
}
newFile := filepath.Join(dir, ".ratatoskr.commit-n.new")
if err := os.WriteFile(newFile, []byte("new-content"), 0o755); err != nil {
t.Fatal(err)
}
u := &Updater{Dir: dir}
if err := u.applySwap(dir, exe); err != nil {
t.Fatalf("applySwap err = %v", err)
}
got, _ := os.ReadFile(exe)
if string(got) != "new-content" {
t.Errorf("exe content = %q want new-content", got)
}
// старый бэкап в .old
old, _ := os.ReadFile(filepath.Join(dir, CrashSafeTag))
if string(old) != "old" {
t.Errorf(".old content = %q want old", old)
}
}
func TestErrorsAs(t *testing.T) {
// проверка классификатора через errorsAs на Error(U4)
err := ue(U4, "verify", os.ErrPermission)
var uerr *Error
if !errorsAs(err, &uerr) {
t.Fatal("errorsAs failed to unwrap *Error")
}
if uerr.Code != U4 {
t.Errorf("code = %v want U4", uerr.Code)
}
if !errors.Is(err, os.ErrPermission) {
t.Errorf("errors.Is(err, ErrPermission) = false, want true")
}
}
// errorsAs — тонкая обёртка errors.As для классификатора update.Error.
func errorsAs(err error, target **Error) bool {
type causer interface{ Unwrap() error }
for err != nil {
if e, ok := err.(*Error); ok {
*target = e
return true
}
c, ok := err.(causer)
if !ok {
return false
}
err = c.Unwrap()
}
return false
}