Files
OwnCord/Server/updater/cancelled_caller_test.go
T
J3vbandClaude Opus 5 4b56631a1b fix(updater): detach update fetches from the caller's context (F7)
Both process-wide negative caches were filled with errors from a fetch driven
by the caller's context, so an unauthenticated client that aborted a request
which hit a cache miss wrote its own context.Canceled into a 5-minute shared
failure cache -- also blocking the owner's admin panel. The outbound fetch at
both sites is now driven by a server-owned context (WithoutCancel plus a 30s
timeout), so caller cancellation can no longer reach the cache while genuine
upstream failures are still cached.

Verified by a panel of agents; the added tests fail against an unpatched base
with a poisoned cache, and the pre-existing error-caching tests still pass.
The missing singleflight on CheckForUpdate is pre-existing and unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:04:44 +02:00

75 lines
2.4 KiB
Go

package updater
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
// The release and text-asset caches are process-wide and shared by the
// unauthenticated client-update endpoint and the owner-only admin handlers, so
// a caller that cancels its request must never leave a failure behind for the
// next caller. An already-cancelled context stands in for a client that aborts
// its connection mid-flight: both surface as context.Canceled out of the
// outbound fetch.
func TestCheckForUpdateCancelledCallerDoesNotPoisonCache(t *testing.T) {
release := newTestRelease("v1.2.0", "notes", "https://github.com/J3vb/OwnCord/releases/tag/v1.2.0",
"https://github.com/J3vb/OwnCord/releases/download/v1.2.0")
var hits atomic.Int64
mux := http.NewServeMux()
mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(release)
})
srv := httptest.NewServer(mux)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
cancelled, cancel := context.WithCancel(context.Background())
cancel()
// Outcome for the aborted caller itself is deliberately not asserted; what
// matters is what it leaves in the shared cache.
_, _ = u.CheckForUpdate(cancelled)
info, err := u.CheckForUpdate(context.Background())
if err != nil {
t.Fatalf("second caller got a poisoned cache: %v", err)
}
if info.Latest != "v1.2.0" {
t.Fatalf("Latest = %q, want %q", info.Latest, "v1.2.0")
}
if got := hits.Load(); got != 1 {
t.Fatalf("outbound fetches = %d, want 1 (upstream consulted once, result cached)", got)
}
}
func TestFetchTextAssetCachedCancelledCallerDoesNotPoisonCache(t *testing.T) {
var fail atomic.Bool
var hits atomic.Int64
srv := textAssetServer(t, &fail, &hits)
u := newTestUpdater(srv.URL, "1.0.0")
url := srv.URL + "/asset"
cancelled, cancel := context.WithCancel(context.Background())
cancel()
_, _ = u.FetchTextAssetCached(cancelled, url)
content, err := u.FetchTextAssetCached(context.Background(), url)
if err != nil {
t.Fatalf("second caller got a poisoned cache: %v", err)
}
if content != "asset-body" {
t.Fatalf("content = %q, want %q", content, "asset-body")
}
if got := hits.Load(); got != 1 {
t.Fatalf("outbound fetches = %d, want 1 (upstream consulted once, result cached)", got)
}
}