feat: signed releases, release feed, notification (read-only)
This commit is contained in:
1 parent
a49eec909c
commit
834493a4f5
49 files changed
+4191
-18
No files matched your search
@@ -0,0 +1,312 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/update/releasesign"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const placeholderPublicKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
type signingKeyPair struct {
|
||||
privatePath string
|
||||
publicPath string
|
||||
}
|
||||
|
||||
func runReleaseSign(args ...string) (int, string) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := releasesign.Run(args, &stdout, &stderr)
|
||||
return code, stdout.String() + stderr.String()
|
||||
}
|
||||
|
||||
func newSigningKeyPair(t *testing.T, dir string) signingKeyPair {
|
||||
t.Helper()
|
||||
pair := signingKeyPair{privatePath: filepath.Join(dir, "release.key"), publicPath: filepath.Join(dir, "release.pub")}
|
||||
if code, output := runReleaseSign("keygen", pair.privatePath, "--pub-out", pair.publicPath); code != 0 {
|
||||
t.Fatalf("keygen failed: %s", output)
|
||||
}
|
||||
return pair
|
||||
}
|
||||
|
||||
func writeChecksums(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "checksums.txt")
|
||||
if err := os.WriteFile(path, []byte("abc renewcms_v1_linux_amd64\ndef renewcms_v1_darwin_arm64\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestReleaseSigningRoundTrip(t *testing.T) {
|
||||
t.Run("Release signing round trip with a throwaway key", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pair := newSigningKeyPair(t, dir)
|
||||
file := writeChecksums(t, dir)
|
||||
|
||||
if code, output := runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", pair.publicPath); code != 0 {
|
||||
t.Fatalf("sign failed: %s", output)
|
||||
}
|
||||
if code, output := runReleaseSign("verify", file, "--pub", pair.publicPath); code != 0 {
|
||||
t.Fatalf("verify failed: %s", output)
|
||||
}
|
||||
|
||||
content, _ := os.ReadFile(file)
|
||||
content[0] ^= 0x01
|
||||
if err := os.WriteFile(file, content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, output := runReleaseSign("verify", file, "--pub", pair.publicPath)
|
||||
if code == 0 || !strings.Contains(output, "signature does not match") {
|
||||
t.Errorf("a changed file must fail verify with a clear message: %d %q", code, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify with a different public key fails", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pair := newSigningKeyPair(t, dir)
|
||||
other := newSigningKeyPair(t, t.TempDir())
|
||||
file := writeChecksums(t, dir)
|
||||
runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", pair.publicPath)
|
||||
|
||||
if code, _ := runReleaseSign("verify", file, "--pub", other.publicPath); code == 0 {
|
||||
t.Error("verify with another public key must fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sign takes the private key from RELEASE_SIGNING_KEY", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pair := newSigningKeyPair(t, dir)
|
||||
file := writeChecksums(t, dir)
|
||||
t.Setenv("RELEASE_SIGNING_KEY", pair.privatePath)
|
||||
|
||||
if code, output := runReleaseSign("sign", file, "--pub", pair.publicPath); code != 0 {
|
||||
t.Fatalf("sign failed: %s", output)
|
||||
}
|
||||
if code, output := runReleaseSign("verify", file, "--pub", pair.publicPath); code != 0 {
|
||||
t.Fatalf("verify failed: %s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sign without any key says so", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pair := newSigningKeyPair(t, dir)
|
||||
t.Setenv("RELEASE_SIGNING_KEY", "")
|
||||
code, output := runReleaseSign("sign", writeChecksums(t, dir), "--pub", pair.publicPath)
|
||||
if code == 0 || !strings.Contains(output, "RELEASE_SIGNING_KEY") {
|
||||
t.Errorf("code = %d output = %q", code, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sign with a missing key file names the path", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pair := newSigningKeyPair(t, dir)
|
||||
missing := filepath.Join(dir, "missing.key")
|
||||
code, output := runReleaseSign("sign", writeChecksums(t, dir), "--key", missing, "--pub", pair.publicPath)
|
||||
if code == 0 || !strings.Contains(output, missing) {
|
||||
t.Errorf("code = %d output = %q", code, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseSignKeygen(t *testing.T) {
|
||||
t.Run("keygen refuses a path inside the repository", func(t *testing.T) {
|
||||
repoRoot, err := filepath.Abs("..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inside := filepath.Join(repoRoot, "tmp", "releasesign-test.key")
|
||||
t.Cleanup(func() { os.Remove(inside) })
|
||||
|
||||
code, output := runReleaseSign("keygen", inside)
|
||||
|
||||
if code == 0 || !strings.Contains(output, "inside the repository") {
|
||||
t.Errorf("code = %d output = %q", code, output)
|
||||
}
|
||||
if _, err := os.Stat(inside); err == nil {
|
||||
t.Error("nothing may be written inside the repository")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keygen never overwrites an existing key", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pair := newSigningKeyPair(t, dir)
|
||||
before, _ := os.ReadFile(pair.privatePath)
|
||||
|
||||
code, _ := runReleaseSign("keygen", pair.privatePath)
|
||||
|
||||
after, _ := os.ReadFile(pair.privatePath)
|
||||
if code == 0 || !bytes.Equal(before, after) {
|
||||
t.Errorf("code = %d, key changed = %v", code, !bytes.Equal(before, after))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keygen prints the public key and where to commit it", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
code, output := runReleaseSign("keygen", filepath.Join(dir, "k"))
|
||||
if code != 0 || !strings.Contains(output, "internal/infrastructure/update/release.pub") {
|
||||
t.Errorf("code = %d output = %q", code, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseSignPlaceholderKey(t *testing.T) {
|
||||
t.Run("The placeholder public key is refused", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pair := newSigningKeyPair(t, dir)
|
||||
placeholder := filepath.Join(dir, "placeholder.pub")
|
||||
if err := os.WriteFile(placeholder, []byte(placeholderPublicKey+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := writeChecksums(t, dir)
|
||||
|
||||
signCode, signOutput := runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", placeholder)
|
||||
verifyCode, verifyOutput := runReleaseSign("verify", file, "--pub", placeholder)
|
||||
|
||||
for _, output := range []string{signOutput, verifyOutput} {
|
||||
if !strings.Contains(output, "placeholder public key has not been replaced") {
|
||||
t.Errorf("output = %q", output)
|
||||
}
|
||||
}
|
||||
if signCode == 0 || verifyCode == 0 {
|
||||
t.Errorf("sign = %d verify = %d", signCode, verifyCode)
|
||||
}
|
||||
if _, err := os.Stat(file + ".sig"); err == nil {
|
||||
t.Error("no .sig may be written")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a malformed public key is refused", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bad := filepath.Join(dir, "bad.pub")
|
||||
_ = os.WriteFile(bad, []byte("not base64!"), 0o600)
|
||||
if code, _ := runReleaseSign("verify", writeChecksums(t, dir), "--pub", bad); code == 0 {
|
||||
t.Error("a malformed key must fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseSignKeyMismatch(t *testing.T) {
|
||||
t.Run("sign refuses a private key that does not match the public key", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
signer := newSigningKeyPair(t, dir)
|
||||
other := newSigningKeyPair(t, t.TempDir())
|
||||
file := writeChecksums(t, dir)
|
||||
|
||||
code, output := runReleaseSign("sign", file, "--key", signer.privatePath, "--pub", other.publicPath)
|
||||
|
||||
if code == 0 || !strings.Contains(output, "does not match") {
|
||||
t.Errorf("code = %d output = %q", code, output)
|
||||
}
|
||||
if _, err := os.Stat(file + ".sig"); err == nil {
|
||||
t.Error("no .sig may be written")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseSignUsage(t *testing.T) {
|
||||
for name, args := range map[string][]string{
|
||||
"no command": nil,
|
||||
"unknown command": {"explode"},
|
||||
"missing file": {"verify"},
|
||||
"unknown flag": {"verify", "x", "--nope", "1"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if code, _ := runReleaseSign(args...); code == 0 {
|
||||
t.Error("expected a non-zero exit")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseSignChecksums(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for name, content := range map[string]string{
|
||||
"renewcms_v2.5.0_linux_amd64": "linux",
|
||||
"renewcms_v2.5.0_windows_amd64.exe": "windows",
|
||||
"renewcms_v2.4.0_linux_amd64": "old",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("writes the SHA-256 of the matching binaries only", func(t *testing.T) {
|
||||
code, output := runReleaseSign("checksums", dir, "--match", "renewcms_v2.5.0_*")
|
||||
if code != 0 {
|
||||
t.Fatalf("checksums failed: %s", output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "checksums.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
||||
if len(lines) != 2 || !strings.HasSuffix(lines[0], " renewcms_v2.5.0_linux_amd64") || !strings.HasSuffix(lines[1], " renewcms_v2.5.0_windows_amd64.exe") {
|
||||
t.Fatalf("checksums.txt = %q", content)
|
||||
}
|
||||
sum := sha256.Sum256([]byte("linux"))
|
||||
if want := hex.EncodeToString(sum[:]) + " renewcms_v2.5.0_linux_amd64"; lines[0] != want {
|
||||
t.Errorf("line = %q, want %q", lines[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a second run does not list checksums.txt or its signature", func(t *testing.T) {
|
||||
runReleaseSign("checksums", dir, "--match", "*")
|
||||
content, _ := os.ReadFile(filepath.Join(dir, "checksums.txt"))
|
||||
if strings.Contains(string(content), "checksums.txt") {
|
||||
t.Errorf("checksums.txt lists itself: %q", content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no match is an error", func(t *testing.T) {
|
||||
if code, _ := runReleaseSign("checksums", dir, "--match", "nothing_*"); code == 0 {
|
||||
t.Error("expected a non-zero exit")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseSignFailsOnUnwritableOutputs(t *testing.T) {
|
||||
t.Run("keygen reports a public key that cannot be written", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
code, _ := runReleaseSign("keygen", filepath.Join(dir, "release.key"), "--pub-out", filepath.Join(dir, "missing", "release.pub"))
|
||||
if code == 0 {
|
||||
t.Fatal("keygen exited 0 although the public key could not be written")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keygen reports a trailing flag with no value without panicking", func(t *testing.T) {
|
||||
code, _ := runReleaseSign("keygen", filepath.Join(t.TempDir(), "release.key"), "--pub-out")
|
||||
if code == 0 {
|
||||
t.Fatal("keygen exited 0 for a flag with no value")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sign reports a signature that cannot be written", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pair := newSigningKeyPair(t, dir)
|
||||
file := writeChecksums(t, dir)
|
||||
if err := os.Mkdir(file+".sig", 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _ := runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", pair.publicPath); code == 0 {
|
||||
t.Fatal("sign exited 0 although the signature could not be written")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("checksums reports a checksums file that cannot be written", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "a.bin"), []byte("a"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "checksums.txt"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _ := runReleaseSign("checksums", dir, "--match", "*.bin"); code == 0 {
|
||||
t.Fatal("checksums exited 0 although checksums.txt could not be written")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/update"
|
||||
infraUpdate "RenewCMS/internal/infrastructure/update"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const giteaReleasesPath = "/api/v1/repos/fsylvain/RenewCMS/releases"
|
||||
|
||||
func newGiteaFeedServer(t *testing.T, status int, body string) (*httptest.Server, *[]string) {
|
||||
t.Helper()
|
||||
var paths []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server, &paths
|
||||
}
|
||||
|
||||
func TestGiteaReleaseFeed(t *testing.T) {
|
||||
list := `[
|
||||
{"tag_name":"v2.4.0-beta","html_url":"https://git.example/r/v2.4.0-beta","body":"- Four","draft":false,"prerelease":true,"published_at":"2026-08-01T10:00:00Z"},
|
||||
{"tag_name":"v9.0.0","html_url":"https://git.example/r/v9.0.0","body":"- Draft","draft":true,"prerelease":false,"published_at":"2026-09-30T10:00:00Z"},
|
||||
{"tag_name":"v2.5.0-beta","html_url":"https://git.example/r/v2.5.0-beta","body":"## New\n- **Media** library\n- Fixed [caching](http://x) bug","draft":false,"prerelease":true,"published_at":"2026-09-24T08:30:00Z"},
|
||||
{"tag_name":"v2.3.0-beta","html_url":"https://git.example/r/v2.3.0-beta","body":"","draft":false,"prerelease":true,"published_at":"2026-07-01T10:00:00Z"}
|
||||
]`
|
||||
|
||||
t.Run("requests the release list and returns the newest published release", func(t *testing.T) {
|
||||
server, paths := newGiteaFeedServer(t, http.StatusOK, list)
|
||||
|
||||
release, err := infraUpdate.NewGiteaReleaseFeed(server.URL + "/").Latest()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Latest: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(*paths, []string{giteaReleasesPath}) {
|
||||
t.Errorf("paths = %v", *paths)
|
||||
}
|
||||
if release.Tag != "v2.5.0-beta" || release.URL != "https://git.example/r/v2.5.0-beta" {
|
||||
t.Errorf("release = %+v", release)
|
||||
}
|
||||
if want := []string{"Media library", "Fixed caching bug"}; !reflect.DeepEqual(release.Notes.Items, want) {
|
||||
t.Errorf("items = %v", release.Notes.Items)
|
||||
}
|
||||
if want := time.Date(2026, 9, 24, 8, 30, 0, 0, time.UTC); !release.PublishedAt.Equal(want) || !release.Notes.ReleasedAt.Equal(want) {
|
||||
t.Errorf("published = %v, notes released = %v", release.PublishedAt, release.Notes.ReleasedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a final release beats a beta of the same number", func(t *testing.T) {
|
||||
server, _ := newGiteaFeedServer(t, http.StatusOK, `[{"tag_name":"v2.6.0-beta","published_at":"2026-09-01T00:00:00Z"},{"tag_name":"v2.6.0","published_at":"2026-09-02T00:00:00Z"}]`)
|
||||
release, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest()
|
||||
if err != nil || release.Tag != "v2.6.0" {
|
||||
t.Errorf("release = %+v, err = %v", release, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only drafts is no release", func(t *testing.T) {
|
||||
server, _ := newGiteaFeedServer(t, http.StatusOK, `[{"tag_name":"v9.0.0","draft":true}]`)
|
||||
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrNoRelease) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a 500 is a feed error", func(t *testing.T) {
|
||||
server, _ := newGiteaFeedServer(t, http.StatusInternalServerError, "boom")
|
||||
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid json is a feed error", func(t *testing.T) {
|
||||
server, _ := newGiteaFeedServer(t, http.StatusOK, "not json")
|
||||
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unreachable server is a feed error", func(t *testing.T) {
|
||||
server, _ := newGiteaFeedServer(t, http.StatusOK, "[]")
|
||||
url := server.URL
|
||||
server.Close()
|
||||
if _, err := infraUpdate.NewGiteaReleaseFeed(url).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestJSONStatusStore(t *testing.T) {
|
||||
t.Run("The stored result survives a restart", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "data", "update-status.json")
|
||||
saved := newStoredUpdateResult()
|
||||
if err := infraUpdate.NewJSONStatusStore(path).Save(saved); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
loaded, found := infraUpdate.NewJSONStatusStore(path).Load()
|
||||
|
||||
if !found {
|
||||
t.Fatal("expected a stored result")
|
||||
}
|
||||
if loaded.Latest != saved.Latest || loaded.ReleaseURL != saved.ReleaseURL || !loaded.CheckedAt.Equal(saved.CheckedAt) {
|
||||
t.Errorf("loaded = %+v", loaded)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.Notes.Items, saved.Notes.Items) {
|
||||
t.Errorf("items = %v", loaded.Notes.Items)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a second save replaces the first", func(t *testing.T) {
|
||||
store := infraUpdate.NewJSONStatusStore(filepath.Join(t.TempDir(), "update-status.json"))
|
||||
first := newStoredUpdateResult()
|
||||
second := newStoredUpdateResult()
|
||||
second.Latest = "v2.4.0-beta"
|
||||
_ = store.Save(first)
|
||||
_ = store.Save(second)
|
||||
if loaded, _ := store.Load(); loaded.Latest != "v2.4.0-beta" {
|
||||
t.Errorf("latest = %q", loaded.Latest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a missing file reads as no result", func(t *testing.T) {
|
||||
if _, found := infraUpdate.NewJSONStatusStore(filepath.Join(t.TempDir(), "absent.json")).Load(); found {
|
||||
t.Error("a missing file must read as no result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a corrupt file reads as no result", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "update-status.json")
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := infraUpdate.NewJSONStatusStore(path).Load(); found {
|
||||
t.Error("a corrupt file must read as no result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unwritable location is an error", func(t *testing.T) {
|
||||
blocker := filepath.Join(t.TempDir(), "file")
|
||||
if err := os.WriteFile(blocker, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := infraUpdate.NewJSONStatusStore(filepath.Join(blocker, "update-status.json")).Save(newStoredUpdateResult()); err == nil {
|
||||
t.Error("expected an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommittedReleasePublicKeyHasTheRightShape(t *testing.T) {
|
||||
content, err := os.ReadFile(filepath.Join("..", "internal", "infrastructure", "update", "release.pub"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(content)))
|
||||
if err != nil || len(decoded) != ed25519.PublicKeySize {
|
||||
t.Fatalf("release.pub must be base64 of %d bytes: %v", ed25519.PublicKeySize, err)
|
||||
}
|
||||
if _, err := infraUpdate.EmbeddedPublicKey(); err != nil {
|
||||
t.Errorf("embedded key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaReleaseFeedRejectsAnOversizedBody(t *testing.T) {
|
||||
huge := `[{"tag_name":"v2.5.0","html_url":"u","body":"` + strings.Repeat("a", 5<<20) + `","draft":false,"published_at":"2026-09-24T08:30:00Z"}]`
|
||||
server, _ := newGiteaFeedServer(t, http.StatusOK, huge)
|
||||
|
||||
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
|
||||
t.Fatalf("Latest error = %v, want ErrFeedUnavailable", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/update"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
updateOwnerId = 1
|
||||
updateAdminId = 2
|
||||
updateUnverifiedId = 3
|
||||
updateSecondOwner = 4
|
||||
updateRunning = "v2.2.0-beta"
|
||||
)
|
||||
|
||||
var updateStatusKeys = []string{"available", "can_apply", "checked_at", "current", "error", "latest", "notes", "progress", "release_url", "started_at", "state"}
|
||||
|
||||
type updateHarness struct {
|
||||
router http.Handler
|
||||
token *jwtauth.JWTAuth
|
||||
feed *fakeReleaseFeed
|
||||
store *fakeUpdateStore
|
||||
}
|
||||
|
||||
func newUpdateHarness(t *testing.T, running string) *updateHarness {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
users := newMockUserRepo()
|
||||
users.users[updateOwnerId] = user.User{ID: updateOwnerId, IsVerified: true, Role: user.RoleOwner, TokenVersion: 2}
|
||||
users.users[updateAdminId] = user.User{ID: updateAdminId, IsVerified: true, Role: user.RoleAdmin}
|
||||
users.users[updateUnverifiedId] = user.User{ID: updateUnverifiedId, IsVerified: false, Role: user.RoleAdmin}
|
||||
users.users[updateSecondOwner] = user.User{ID: updateSecondOwner, IsVerified: true, Role: user.RoleOwner}
|
||||
feed := &fakeReleaseFeed{release: update.Release{
|
||||
Tag: "v2.3.0-beta",
|
||||
URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
|
||||
PublishedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC),
|
||||
Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}},
|
||||
}}
|
||||
store := &fakeUpdateStore{}
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
now := func() time.Time { return updateTestCheckedAt }
|
||||
container := &api.UseCases{
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(users),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, running),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now),
|
||||
}
|
||||
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: store}
|
||||
}
|
||||
|
||||
func (h *updateHarness) send(t *testing.T, method, path string, userId uint32, tokenVersion uint32) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if userId != 0 {
|
||||
_, tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (h *updateHarness) sendAs(t *testing.T, method, path string, userId uint32) *httptest.ResponseRecorder {
|
||||
tokenVersion := uint32(0)
|
||||
if userId == updateOwnerId {
|
||||
tokenVersion = 2
|
||||
}
|
||||
return h.send(t, method, path, userId, tokenVersion)
|
||||
}
|
||||
|
||||
func decodeUpdateBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("body is not json: %v: %s", err, rec.Body.String())
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func assertUpdateKeys(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
keys := make([]string, 0, len(body))
|
||||
for key := range body {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) != len(updateStatusKeys) {
|
||||
t.Fatalf("keys = %v, want %v", keys, updateStatusKeys)
|
||||
}
|
||||
for i := range keys {
|
||||
if keys[i] != updateStatusKeys[i] {
|
||||
t.Fatalf("keys = %v, want %v", keys, updateStatusKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStatusHTTP(t *testing.T) {
|
||||
t.Run("GET returns the idle shape, never applicable", func(t *testing.T) {
|
||||
h := newUpdateHarness(t, updateRunning)
|
||||
h.store.result = newStoredUpdateResult()
|
||||
h.store.found = true
|
||||
|
||||
rec := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := decodeUpdateBody(t, rec)
|
||||
assertUpdateKeys(t, body)
|
||||
if body["current"] != updateRunning || body["latest"] != "v2.3.0-beta" || body["available"] != true || body["can_apply"] != false || body["state"] != "idle" {
|
||||
t.Errorf("body = %v", body)
|
||||
}
|
||||
if body["release_url"] != h.store.result.ReleaseURL || body["checked_at"] != "2026-09-30T09:41:00Z" {
|
||||
t.Errorf("release_url = %v, checked_at = %v", body["release_url"], body["checked_at"])
|
||||
}
|
||||
if body["progress"] != nil || body["error"] != nil || body["started_at"] != nil {
|
||||
t.Errorf("progress, error and started_at must be null: %v", body)
|
||||
}
|
||||
notes := body["notes"].(map[string]any)
|
||||
if notes["version"] != "v2.3.0-beta" || len(notes["items"].([]any)) != 1 || notes["released_at"] != "2026-09-24T08:00:00Z" {
|
||||
t.Errorf("notes = %v", notes)
|
||||
}
|
||||
if h.feed.calls != 0 {
|
||||
t.Errorf("feed calls = %d", h.feed.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET before any check", func(t *testing.T) {
|
||||
h := newUpdateHarness(t, updateRunning)
|
||||
|
||||
rec := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
|
||||
|
||||
body := decodeUpdateBody(t, rec)
|
||||
assertUpdateKeys(t, body)
|
||||
if rec.Code != http.StatusOK || body["available"] != false || body["latest"] != "" || body["state"] != "idle" {
|
||||
t.Errorf("status = %d body = %v", rec.Code, body)
|
||||
}
|
||||
for _, key := range []string{"release_url", "notes", "checked_at", "progress", "error", "started_at"} {
|
||||
if body[key] != nil {
|
||||
t.Errorf("%s = %v, want null", key, body[key])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for name, userId := range map[string]uint32{"owner": updateOwnerId, "admin": updateAdminId} {
|
||||
t.Run("GET is open to any verified user: "+name, func(t *testing.T) {
|
||||
rec := newUpdateHarness(t, updateRunning).sendAs(t, http.MethodGet, "/update", userId)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("GET without a cookie", func(t *testing.T) {
|
||||
rec := newUpdateHarness(t, updateRunning).send(t, http.MethodGet, "/update", 0, 0)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET with a stale token", func(t *testing.T) {
|
||||
rec := newUpdateHarness(t, updateRunning).send(t, http.MethodGet, "/update", updateOwnerId, 1)
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
|
||||
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET by an unverified account", func(t *testing.T) {
|
||||
rec := newUpdateHarness(t, updateRunning).sendAs(t, http.MethodGet, "/update", updateUnverifiedId)
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
|
||||
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateCheckHTTP(t *testing.T) {
|
||||
t.Run("The owner checks and the result is stored", func(t *testing.T) {
|
||||
h := newUpdateHarness(t, updateRunning)
|
||||
|
||||
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := decodeUpdateBody(t, rec)
|
||||
assertUpdateKeys(t, body)
|
||||
if body["available"] != true || body["latest"] != "v2.3.0-beta" || body["checked_at"] != "2026-09-30T09:41:00Z" {
|
||||
t.Errorf("body = %v", body)
|
||||
}
|
||||
if h.store.result.Latest != "v2.3.0-beta" || !h.store.result.CheckedAt.Equal(updateTestCheckedAt) {
|
||||
t.Errorf("stored = %+v", h.store.result)
|
||||
}
|
||||
later := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
|
||||
if later.Body.String() != rec.Body.String() {
|
||||
t.Errorf("later GET = %s, check = %s", later.Body.String(), rec.Body.String())
|
||||
}
|
||||
if h.feed.calls != 1 {
|
||||
t.Errorf("feed calls = %d", h.feed.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A check with nothing newer", func(t *testing.T) {
|
||||
h := newUpdateHarness(t, "v2.3.0-beta")
|
||||
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
|
||||
if rec.Code != http.StatusOK || decodeUpdateBody(t, rec)["available"] != false {
|
||||
t.Errorf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A dev build never reports an update", func(t *testing.T) {
|
||||
h := newUpdateHarness(t, "dev")
|
||||
h.feed.release.Tag = "v9.9.9"
|
||||
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
|
||||
if rec.Code != http.StatusOK || decodeUpdateBody(t, rec)["available"] != false {
|
||||
t.Errorf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An admin cannot check", func(t *testing.T) {
|
||||
h := newUpdateHarness(t, updateRunning)
|
||||
rec := h.sendAs(t, http.MethodPost, "/update/check", updateAdminId)
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
|
||||
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if h.feed.calls != 0 {
|
||||
t.Errorf("feed calls = %d", h.feed.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A check without a cookie is unauthorized", func(t *testing.T) {
|
||||
h := newUpdateHarness(t, updateRunning)
|
||||
if rec := h.send(t, http.MethodPost, "/update/check", 0, 0); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Feed failure keeps the last result", func(t *testing.T) {
|
||||
h := newUpdateHarness(t, updateRunning)
|
||||
h.store.result = newStoredUpdateResult()
|
||||
h.store.found = true
|
||||
h.feed.err = update.ErrFeedUnavailable
|
||||
|
||||
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
if h.store.result.Latest != "v2.3.0-beta" || !h.store.result.CheckedAt.Equal(updateTestCheckedAt) || h.store.saves != 0 {
|
||||
t.Errorf("store changed: %+v", h.store.result)
|
||||
}
|
||||
if body := rec.Body.String(); body != "Internal server error\n" {
|
||||
t.Errorf("body leaks detail: %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Check is rate limited per user", func(t *testing.T) {
|
||||
h := newUpdateHarness(t, updateRunning)
|
||||
loginLimited := middleware.NewRateLimiter(5, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
|
||||
var codes []int
|
||||
var last *httptest.ResponseRecorder
|
||||
for i := 0; i < 6; i++ {
|
||||
last = h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
|
||||
codes = append(codes, last.Code)
|
||||
}
|
||||
|
||||
for i, code := range codes[:5] {
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("check %d status = %d", i+1, code)
|
||||
}
|
||||
}
|
||||
if codes[5] != http.StatusTooManyRequests || last.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" {
|
||||
t.Fatalf("6th status = %d body = %q", codes[5], last.Body.String())
|
||||
}
|
||||
if seconds, err := strconv.Atoi(last.Header().Get("Retry-After")); err != nil || seconds < 1 {
|
||||
t.Errorf("Retry-After = %q", last.Header().Get("Retry-After"))
|
||||
}
|
||||
if h.feed.calls != 5 {
|
||||
t.Errorf("feed calls = %d", h.feed.calls)
|
||||
}
|
||||
if other := h.sendAs(t, http.MethodPost, "/update/check", updateSecondOwner); other.Code != http.StatusOK {
|
||||
t.Errorf("another owner status = %d", other.Code)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
loginLimited.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/login", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("auth limiter request %d status = %d", i+1, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download and restart are not mounted", func(t *testing.T) {
|
||||
h := newUpdateHarness(t, updateRunning)
|
||||
for _, path := range []string{"/update/download", "/update/restart"} {
|
||||
if rec := h.sendAs(t, http.MethodPost, path, updateOwnerId); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s status = %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/update"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeReleaseFeed struct {
|
||||
release update.Release
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
var _ update.ReleaseFeed = &fakeReleaseFeed{}
|
||||
|
||||
func (f *fakeReleaseFeed) Latest() (update.Release, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return update.Release{}, f.err
|
||||
}
|
||||
return f.release, nil
|
||||
}
|
||||
|
||||
type fakeUpdateStore struct {
|
||||
result update.CheckResult
|
||||
found bool
|
||||
saveErr error
|
||||
saves int
|
||||
}
|
||||
|
||||
var _ update.StatusStore = &fakeUpdateStore{}
|
||||
|
||||
func (f *fakeUpdateStore) Load() (update.CheckResult, bool) {
|
||||
return f.result, f.found
|
||||
}
|
||||
|
||||
func (f *fakeUpdateStore) Save(result update.CheckResult) error {
|
||||
f.saves++
|
||||
if f.saveErr != nil {
|
||||
return f.saveErr
|
||||
}
|
||||
f.result = result
|
||||
f.found = true
|
||||
return nil
|
||||
}
|
||||
|
||||
var updateTestCheckedAt = time.Date(2026, 9, 30, 9, 41, 0, 0, time.UTC)
|
||||
|
||||
func newStoredUpdateResult() update.CheckResult {
|
||||
return update.CheckResult{
|
||||
Latest: "v2.3.0-beta",
|
||||
ReleaseURL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
|
||||
Notes: update.ReleaseNotes{
|
||||
Version: "v2.3.0-beta",
|
||||
ReleasedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC),
|
||||
Items: []string{"Media library"},
|
||||
},
|
||||
CheckedAt: updateTestCheckedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionComparison(t *testing.T) {
|
||||
cases := []struct {
|
||||
current, latest string
|
||||
available bool
|
||||
}{
|
||||
{"v2.2.0-beta", "v2.3.0-beta", true},
|
||||
{"v2.5.0-beta", "v2.5.0", true},
|
||||
{"v2.5.0", "v2.5.0-beta", false},
|
||||
{"v2.9.0", "v2.10.0", true},
|
||||
{"v2.5.0-beta", "v2.5.0-beta", false},
|
||||
{"v2.5.0", "v2.4.9", false},
|
||||
{"dev", "v9.9.9", false},
|
||||
{"v2.5.0-beta.2", "v2.5.0-beta.10", true},
|
||||
{"v2.5.0-alpha", "v2.5.0-beta", true},
|
||||
{"2.0.0-beta", "v2.1.0-beta", true},
|
||||
{"v2.5", "v2.5.0", false},
|
||||
{"v2.2.0", "garbage", false},
|
||||
{"v1.0.0-1", "v1.0.0-alpha", true},
|
||||
{"v1.0.0-alpha", "v1.0.0-1", false},
|
||||
{"v1.0.0-beta", "v1.0.0-beta.1", true},
|
||||
{"v1.0.0-beta.1", "v1.0.0-beta", false},
|
||||
{"v1.0.0-beta.2", "v1.0.0-beta.1", false},
|
||||
{"v1.0.0-beta", "v1.0.0-alpha", false},
|
||||
{"v1.0.0", "v1.0.0", false},
|
||||
{"v1.1.0", "v1.0.9", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.current+" to "+c.latest, func(t *testing.T) {
|
||||
if got := update.IsNewer(c.current, c.latest); got != c.available {
|
||||
t.Errorf("IsNewer(%q, %q) = %v, want %v", c.current, c.latest, got, c.available)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVersionRejectsGarbage(t *testing.T) {
|
||||
for _, raw := range []string{"", "dev", "v", "v1.x.0", "v1.2.3-", "v-1.0.0"} {
|
||||
if _, err := update.ParseVersion(raw); !errors.Is(err, update.ErrInvalidVersion) {
|
||||
t.Errorf("ParseVersion(%q) error = %v, want ErrInvalidVersion", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewestReleaseIsChosenRegardlessOfOrder(t *testing.T) {
|
||||
releases := []update.Release{{Tag: "v2.4.0-beta"}, {Tag: "v2.5.0-beta"}, {Tag: "v2.3.0-beta"}}
|
||||
newest, err := update.Newest(releases)
|
||||
if err != nil || newest.Tag != "v2.5.0-beta" {
|
||||
t.Fatalf("Newest = %q, %v, want v2.5.0-beta", newest.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewestReleaseOfNothingIsAnError(t *testing.T) {
|
||||
if _, err := update.Newest(nil); !errors.Is(err, update.ErrNoRelease) {
|
||||
t.Errorf("error = %v, want ErrNoRelease", err)
|
||||
}
|
||||
if _, err := update.Newest([]update.Release{{Tag: "nightly"}}); !errors.Is(err, update.ErrNoRelease) {
|
||||
t.Errorf("error = %v, want ErrNoRelease for an unparsable tag", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNotesBecomeAPlainTextList(t *testing.T) {
|
||||
published := time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC)
|
||||
body := "## What's new\n- **Media** library\n- Fixed [caching](http://x) bug\n\n* Faster start"
|
||||
|
||||
notes := update.BuildNotes("v2.3.0-beta", published, body)
|
||||
|
||||
want := []string{"Media library", "Fixed caching bug", "Faster start"}
|
||||
if !reflect.DeepEqual(notes.Items, want) {
|
||||
t.Errorf("items = %v, want %v", notes.Items, want)
|
||||
}
|
||||
if !notes.ReleasedAt.Equal(published) || notes.Version != "v2.3.0-beta" {
|
||||
t.Errorf("notes = %+v", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNotesWithoutBulletsHaveNoItems(t *testing.T) {
|
||||
notes := update.BuildNotes("v1.0.0", time.Time{}, "Just an intro.\n\n**Group**\n")
|
||||
if notes.Items == nil || len(notes.Items) != 0 {
|
||||
t.Errorf("items = %#v, want an empty non-nil list", notes.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNotesStripInlineMarkdown(t *testing.T) {
|
||||
notes := update.BuildNotes("v1.0.0", time.Time{}, "- Run `task build` and *enjoy* __it__")
|
||||
if want := []string{"Run task build and enjoy it"}; !reflect.DeepEqual(notes.Items, want) {
|
||||
t.Errorf("items = %v, want %v", notes.Items, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUpdateStatusUseCase(t *testing.T) {
|
||||
t.Run("never checked", func(t *testing.T) {
|
||||
status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{}, "v2.2.0-beta").GetStatus()
|
||||
|
||||
if status.Current != "v2.2.0-beta" || status.Latest != "" || status.Available {
|
||||
t.Errorf("status = %+v", status)
|
||||
}
|
||||
if status.ReleaseURL != nil || status.Notes != nil || status.CheckedAt != nil || status.Progress != nil || status.Error != nil || status.StartedAt != nil {
|
||||
t.Errorf("never-checked values must be nil: %+v", status)
|
||||
}
|
||||
if status.State != update.StateIdle || status.CanApply {
|
||||
t.Errorf("state = %q, can_apply = %v", status.State, status.CanApply)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stored result", func(t *testing.T) {
|
||||
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
|
||||
|
||||
status := useCases.NewGetUpdateStatusUseCase(store, "v2.2.0-beta").GetStatus()
|
||||
|
||||
if status.Latest != "v2.3.0-beta" || !status.Available || status.CanApply || status.State != update.StateIdle {
|
||||
t.Errorf("status = %+v", status)
|
||||
}
|
||||
if status.ReleaseURL == nil || *status.ReleaseURL != store.result.ReleaseURL {
|
||||
t.Errorf("release_url = %v", status.ReleaseURL)
|
||||
}
|
||||
if status.CheckedAt == nil || !status.CheckedAt.Equal(updateTestCheckedAt) {
|
||||
t.Errorf("checked_at = %v", status.CheckedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dev build never reports an update", func(t *testing.T) {
|
||||
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
|
||||
if useCases.NewGetUpdateStatusUseCase(store, "dev").GetStatus().Available {
|
||||
t.Error("a dev build must not report an update")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckForUpdateUseCase(t *testing.T) {
|
||||
newRelease := update.Release{Tag: "v2.3.0-beta", URL: "https://git.example/r/v2.3.0-beta", PublishedAt: updateTestCheckedAt}
|
||||
now := func() time.Time { return updateTestCheckedAt }
|
||||
|
||||
t.Run("stores the result and reports it", func(t *testing.T) {
|
||||
store := &fakeUpdateStore{}
|
||||
feed := &fakeReleaseFeed{release: newRelease}
|
||||
|
||||
status, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check()
|
||||
|
||||
if err != nil || !status.Available || status.Latest != "v2.3.0-beta" {
|
||||
t.Fatalf("status = %+v, err = %v", status, err)
|
||||
}
|
||||
if store.result.Latest != "v2.3.0-beta" || store.result.ReleaseURL != newRelease.URL || !store.result.CheckedAt.Equal(updateTestCheckedAt) {
|
||||
t.Errorf("stored = %+v", store.result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nothing newer", func(t *testing.T) {
|
||||
feed := &fakeReleaseFeed{release: update.Release{Tag: "v2.2.0-beta"}}
|
||||
status, err := useCases.NewCheckForUpdateUseCase(feed, &fakeUpdateStore{}, "v2.2.0-beta", now).Check()
|
||||
if err != nil || status.Available {
|
||||
t.Errorf("status = %+v, err = %v", status, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("feed failure keeps the previous result", func(t *testing.T) {
|
||||
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
|
||||
feed := &fakeReleaseFeed{err: update.ErrFeedUnavailable}
|
||||
|
||||
_, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check()
|
||||
|
||||
if !errors.Is(err, update.ErrFeedUnavailable) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
if store.saves != 0 || store.result.Latest != "v2.3.0-beta" {
|
||||
t.Errorf("store changed: %+v saves %d", store.result, store.saves)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure is returned", func(t *testing.T) {
|
||||
store := &fakeUpdateStore{saveErr: errRepositoryFailure}
|
||||
feed := &fakeReleaseFeed{release: newRelease}
|
||||
if _, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check(); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user