fix: harden DynDNS recovery and publish v1.5.1

This commit is contained in:
admin committed 2026-10-10 02:26:47 +02:00
1 parent 3383b91e49
commit 81def77e3c
37 files changed
+2415 -250

No files matched your search

+122 -8
View File
@@ -4,11 +4,13 @@ import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -65,6 +67,7 @@ func (a *fakeUpdateAgent) counts() (status, start int) {
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Parallel()
nextVersion := nextReleaseVersion(t)
const credential = " test-password-or-token-that-must-stay-secret "
var unavailable atomic.Bool
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -78,7 +81,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"tag_name":"v1.5.1","html_url":"https://example.invalid/releases/v1.5.1","draft":false,"prerelease":false}`))
_, _ = fmt.Fprintf(w, `{"tag_name":%q,"html_url":%q,"draft":false,"prerelease":false}`, nextVersion, "https://example.invalid/releases/"+nextVersion)
}))
defer api.Close()
@@ -111,7 +114,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Fatal(err)
}
state := manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError != "" || state.CredentialUsername != "registry-user" {
if !state.UpdateAvailable || state.LatestVersion != nextVersion || state.LastError != "" || state.CredentialUsername != "registry-user" {
t.Fatalf("new release was not detected: %#v", state)
}
settings, err := database.GetSoftwareUpdateSettings(ctx)
@@ -127,7 +130,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
t.Fatal("unavailable Gitea endpoint was reported as successful")
}
state = manager.State()
if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError == "" {
if !state.UpdateAvailable || state.LatestVersion != nextVersion || state.LastError == "" {
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
}
}
@@ -184,12 +187,13 @@ func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) {
func TestRequestUpdateRefreshesStaleAgentStateBeforeStarting(t *testing.T) {
t.Parallel()
nextVersion := nextReleaseVersion(t)
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
manager, database := updateReadyManager(t, agent)
if manager.State().AgentAvailable {
t.Fatal("test precondition failed: cached agent state is already available")
}
if err := manager.RequestUpdate(context.Background(), "v1.5.1"); err != nil {
if err := manager.RequestUpdate(context.Background(), nextVersion); err != nil {
t.Fatalf("live agent was rejected because of stale cached state: %v", err)
}
statusCalls, startCalls := agent.counts()
@@ -200,13 +204,14 @@ func TestRequestUpdateRefreshesStaleAgentStateBeforeStarting(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if settings.DesiredVersion != "v1.5.1" {
if settings.DesiredVersion != nextVersion {
t.Fatalf("target version was not persisted: %#v", settings)
}
}
func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T) {
t.Parallel()
nextVersion := nextReleaseVersion(t)
tests := []struct {
name string
status updateprotocol.Status
@@ -222,7 +227,7 @@ func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T)
t.Parallel()
agent := &fakeUpdateAgent{status: test.status, statusErr: test.statusErr}
manager, database := updateReadyManager(t, agent)
if err := manager.RequestUpdate(context.Background(), "v1.5.1"); err == nil {
if err := manager.RequestUpdate(context.Background(), nextVersion); err == nil {
t.Fatal("unsafe update request was accepted")
}
_, startCalls := agent.counts()
@@ -242,6 +247,7 @@ func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T)
func TestRequestUpdateSerializesParallelClicks(t *testing.T) {
t.Parallel()
nextVersion := nextReleaseVersion(t)
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
manager, _ := updateReadyManager(t, agent)
results := make(chan error, 2)
@@ -249,7 +255,7 @@ func TestRequestUpdateSerializesParallelClicks(t *testing.T) {
for index := 0; index < 2; index++ {
go func() {
<-start
results <- manager.RequestUpdate(context.Background(), "v1.5.1")
results <- manager.RequestUpdate(context.Background(), nextVersion)
}()
}
close(start)
@@ -337,6 +343,7 @@ func compatibleAgentStatus(phase string) updateprotocol.Status {
func updateReadyManager(t *testing.T, agent updateAgent) (*Manager, *store.Store) {
t.Helper()
nextVersion := nextReleaseVersion(t)
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
@@ -363,7 +370,7 @@ func updateReadyManager(t *testing.T, agent updateAgent) (*Manager, *store.Store
if err := manager.SaveCredential(ctx, "registry-user", "registry-password"); err != nil {
t.Fatal(err)
}
if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.5.1", "https://git.example/releases/v1.5.1", time.Now()); err != nil {
if err := database.RecordSoftwareUpdateSuccess(ctx, nextVersion, "https://git.example/releases/"+nextVersion, time.Now()); err != nil {
t.Fatal(err)
}
settings, err := database.GetSoftwareUpdateSettings(ctx)
@@ -385,3 +392,110 @@ func waitForCondition(t *testing.T, timeout time.Duration, condition func() bool
}
t.Fatal("condition did not become true in time")
}
func TestRepositoryCanSwitchPublicPrivateAndBack(t *testing.T) {
t.Parallel()
nextVersion := nextReleaseVersion(t)
var private atomic.Bool
var authenticated atomic.Int32
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !private.Load() {
if r.Header.Get("Authorization") != "" {
t.Error("credentials sent to public repository")
}
} else {
user, password, ok := r.BasicAuth()
if !ok || user != "registry-user" || password != "registry-password" {
http.NotFound(w, r)
return
}
authenticated.Add(1)
}
_, _ = fmt.Fprintf(w, `{"tag_name":%q,"draft":false,"prerelease":false}`, nextVersion)
}))
defer api.Close()
manager, database := updateReadyManager(t, &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)})
manager.apiURL = api.URL
manager.httpClient = api.Client()
ctx := context.Background()
if err := database.SaveSoftwareUpdateCredential(ctx, "", []byte{}, []byte{}); err != nil {
t.Fatal(err)
}
if err := manager.CheckNow(ctx); err != nil {
t.Fatalf("public release requires login: %v", err)
}
private.Store(true)
if err := manager.CheckNow(ctx); !errors.Is(err, ErrCredentialMissing) {
t.Fatalf("missing private access not explained: %v", err)
}
if !manager.State().UpdateAvailable {
t.Fatal("visibility change removed last known release")
}
if err := manager.SaveCredential(ctx, "registry-user", "registry-password"); err != nil {
t.Fatal(err)
}
if err := manager.CheckNow(ctx); err != nil {
t.Fatalf("private 404 did not retry credentials: %v", err)
}
private.Store(false)
if err := database.SaveSoftwareUpdateCredential(ctx, "registry-user", []byte("corrupt"), []byte("corrupt")); err != nil {
t.Fatal(err)
}
if err := manager.CheckNow(ctx); err != nil {
t.Fatalf("public check depends on old secret: %v", err)
}
if authenticated.Load() != 1 {
t.Fatalf("unexpected authenticated requests: %d", authenticated.Load())
}
}
func TestAnonymousUpdateRequiresCapableAgent(t *testing.T) {
t.Parallel()
nextVersion := nextReleaseVersion(t)
for _, capable := range []bool{false, true} {
t.Run(strconv.FormatBool(capable), func(t *testing.T) {
status := compatibleAgentStatus(updateprotocol.PhaseIdle)
status.AnonymousPull = capable
agent := &fakeUpdateAgent{status: status}
manager, database := updateReadyManager(t, agent)
if err := database.SaveSoftwareUpdateCredential(context.Background(), "", []byte{}, []byte{}); err != nil {
t.Fatal(err)
}
settings, err := database.GetSoftwareUpdateSettings(context.Background())
if err != nil {
t.Fatal(err)
}
manager.setStoredState(settings)
err = manager.RequestUpdate(context.Background(), nextVersion)
if !capable {
if err == nil || !strings.Contains(err.Error(), "Compose") || !manager.State().ComposeSyncRequired {
t.Fatalf("legacy agent not handled: %v", err)
}
if len(agent.requests) != 0 {
t.Fatal("legacy agent received incompatible anonymous request")
}
} else {
if err != nil {
t.Fatal(err)
}
if len(agent.requests) != 1 || agent.requests[0].RegistryUsername != "" || agent.requests[0].RegistryToken != "" {
t.Fatal("anonymous request not forwarded")
}
}
})
}
}
// Keep update scenarios newer than the source fallback after every release bump.
func nextReleaseVersion(t *testing.T) string {
t.Helper()
parts := strings.Split(strings.TrimPrefix(buildinfo.Version, "v"), ".")
if len(parts) != 3 {
t.Fatalf("unexpected build version %q", buildinfo.Version)
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
t.Fatal(err)
}
return fmt.Sprintf("v%s.%s.%d", parts[0], parts[1], patch+1)
}