internal/server/release/release_test.go
Ref: Size: 9.3 KiB History
package release
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/a73x/eitri/internal/version"
)
// TestCertifiesGuestHostKeys pins the capability question the create path asks
// of a host: does this agent generate a guest host key for the plane to sign?
// The floor is a release, so every build of it and everything after qualifies,
// while a pre-release of the floor does not — it is genuinely older.
func TestCertifiesGuestHostKeys(t *testing.T) {
yes := []string{"v0.0.4", "v0.0.4-2-gabc1234", "v0.0.5", "v0.1.0", "v1.0.0"}
for _, v := range yes {
if !CertifiesGuestHostKeys(v) {
t.Errorf("CertifiesGuestHostKeys(%q) = false, want true", v)
}
}
no := []string{"v0.0.3", "v0.0.1", "v0.0.4-pre.9", "v0.0.3-7-gabc1234"}
for _, v := range no {
if CertifiesGuestHostKeys(v) {
t.Errorf("CertifiesGuestHostKeys(%q) = true, want false", v)
}
}
}
// TestCertifiesGuestHostKeysRefusesTheUnreadable holds the conservative line:
// a version nothing can parse — including the empty one a host reports before
// it has said anything — proves nothing, so it is treated as too old, exactly
// as the upgrade path never offers such a build an upgrade.
func TestCertifiesGuestHostKeysRefusesTheUnreadable(t *testing.T) {
for _, v := range []string{"", "dev", "v0.0.9-dirty", "v0.0", "latest"} {
if CertifiesGuestHostKeys(v) {
t.Errorf("CertifiesGuestHostKeys(%q) = true, want false", v)
}
}
}
// The floor is spelled as a version, and every refusal quotes it; a typo would
// silently refuse (or admit) the whole fleet.
func TestFirstCertifiedHostKeysIsAReleaseTag(t *testing.T) {
if !version.Ordered(FirstCertifiedHostKeys) {
t.Fatalf("FirstCertifiedHostKeys = %q, which does not parse as a version", FirstCertifiedHostKeys)
}
}
func TestRefreshParsesManifest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"version":"v0.0.2","artifacts":{"eitri-agent":{"linux/amd64":{"url":"https://eitri.sh/dl/v0.0.2/a.tar.gz","sha256":"ab"}}}}`))
}))
defer srv.Close()
c := New(srv.URL)
if err := c.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
m, ok := c.Latest()
if !ok || m.Version != "v0.0.2" {
t.Fatalf("Latest = %+v ok=%v", m, ok)
}
if m.Artifacts["eitri-agent"]["linux/amd64"].SHA256 != "ab" {
t.Fatal("artifact not parsed")
}
}
func TestRefreshErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := New(srv.URL)
if err := c.Refresh(context.Background()); err == nil {
t.Fatal("want error on non-2xx")
}
if _, ok := c.Latest(); ok {
t.Fatal("failed refresh must not populate Latest")
}
}
// TestRefreshStaleBeatsAbsent verifies that once a manifest has been fetched
// successfully, a later failed Refresh leaves the previous manifest in place
// rather than clearing it.
func TestRefreshStaleBeatsAbsent(t *testing.T) {
fail := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if fail {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Write([]byte(`{"version":"v0.0.3","artifacts":{}}`))
}))
defer srv.Close()
c := New(srv.URL)
if err := c.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
fail = true
if err := c.Refresh(context.Background()); err == nil {
t.Fatal("want error on second refresh")
}
m, ok := c.Latest()
if !ok || m.Version != "v0.0.3" {
t.Fatalf("Latest after failed refresh = %+v ok=%v, want stale v0.0.3", m, ok)
}
}
// TestRefreshRejectsEmptyVersion verifies a manifest with an empty version
// string is treated as invalid and does not populate Latest.
func TestRefreshRejectsEmptyVersion(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"version":"","artifacts":{}}`))
}))
defer srv.Close()
c := New(srv.URL)
if err := c.Refresh(context.Background()); err == nil {
t.Fatal("want error on empty version")
}
if _, ok := c.Latest(); ok {
t.Fatal("empty-version refresh must not populate Latest")
}
}
// TestRefreshRejectsMalformedJSON verifies a non-JSON body is a decode error
// and never populates Latest.
func TestRefreshRejectsMalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{not json`))
}))
defer srv.Close()
c := New(srv.URL)
if err := c.Refresh(context.Background()); err == nil {
t.Fatal("want error on malformed JSON")
}
if _, ok := c.Latest(); ok {
t.Fatal("malformed-JSON refresh must not populate Latest")
}
}
// TestPollWarmStartRetriesFastUntilFirstSuccess pins the warm-start behavior:
// while no manifest has ever been fetched, Poll retries on the fast warmRetry
// cadence (not the caller's `every`), so a boot-time blip doesn't leave
// release discovery dark for a full day. Once the first fetch succeeds, Poll
// settles onto `every` — no further calls arrive on the fast cadence.
func TestPollWarmStartRetriesFastUntilFirstSuccess(t *testing.T) {
orig := warmRetry
warmRetry = 20 * time.Millisecond
t.Cleanup(func() { warmRetry = orig })
var mu sync.Mutex
calls := 0
callCh := make(chan int, 10)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls++
n := calls
mu.Unlock()
if n < 3 {
w.WriteHeader(http.StatusInternalServerError)
callCh <- n
return
}
w.Write([]byte(`{"version":"v0.0.2","artifacts":{}}`))
callCh <- n
}))
defer srv.Close()
c := New(srv.URL)
ctx, cancel := context.WithCancel(context.Background())
var errCount int32
done := make(chan struct{})
go func() {
c.Poll(ctx, time.Hour, func(err error) { atomic.AddInt32(&errCount, 1) })
close(done)
}()
// The first two calls fail (n=1,2); the fast warmRetry cadence (not the
// 1h `every`) is what makes them arrive quickly.
for want := 1; want <= 3; want++ {
select {
case n := <-callCh:
if n != want {
t.Fatalf("call order: got %d want %d", n, want)
}
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for call %d (warm retry not firing?)", want)
}
}
if atomic.LoadInt32(&errCount) < 2 {
t.Fatalf("errCount = %d, want >= 2 (the two failed warm-retry attempts)", errCount)
}
// Poll away from the handler; give the third (successful) response time to
// be parsed and stored before asserting on it.
deadline := time.Now().Add(1 * time.Second)
for {
if _, ok := c.Latest(); ok {
break
}
if time.Now().After(deadline) {
t.Fatal("manifest never became available after the successful fetch")
}
time.Sleep(time.Millisecond)
}
m, ok := c.Latest()
if !ok || m.Version != "v0.0.2" {
t.Fatalf("Latest = %+v ok=%v, want v0.0.2", m, ok)
}
// Once fetched, Poll must settle onto `every` (1h): no further call
// arrives within a fast-cadence-sized window.
select {
case n := <-callCh:
t.Fatalf("unexpected extra call %d after first success — should have settled onto `every`", n)
case <-time.After(5 * warmRetry):
}
cancel()
<-done
}
func TestFeatureSupportedBy(t *testing.T) {
f := Feature{Name: "thing", Since: "v0.0.4"}
for _, v := range []string{"v0.0.4", "v0.0.4-2-gabc1234", "v0.0.5", "v1.0.0"} {
assert.True(t, f.SupportedBy(v), v)
}
for _, v := range []string{"v0.0.3", "v0.0.4-pre.9", "v0.0.3-7-gabc1234", "", "dev", "v0.0.9-dirty", "latest"} {
assert.False(t, f.SupportedBy(v), v)
}
}
// A floor that cannot be read supports nothing: nothing can be proven
// against it.
func TestFeatureWithUnparsableFloorSupportsNothing(t *testing.T) {
f := Feature{Name: "pending", Since: "<next tag>"}
assert.False(t, f.SupportedBy("v9.9.9"))
}
// The named floor constants and the one surviving wrapper agree with the
// generic form. DatagramExposures has no wrapper left — refuseBelowFloor took
// over its only call site — so only its constant is checked here; the wrapper
// that used to be compared was removed rather than kept alive by this test.
func TestShippedFloorsAreFeatures(t *testing.T) {
assert.Equal(t, FirstCertifiedHostKeys, CertifiedHostKeys.Since)
assert.Equal(t, FirstDatagramExposures, DatagramExposures.Since)
for _, v := range []string{"", "v0.0.3", "v0.0.4", "v0.0.5", "v0.1.0"} {
assert.Equal(t, CertifiedHostKeys.SupportedBy(v), CertifiesGuestHostKeys(v), v)
}
}
// Every feature's floor is spelled as a version; a typo would silently
// refuse (or admit) the whole fleet.
func TestFeatureFloorsParse(t *testing.T) {
for _, f := range shippedFeatures {
if !version.Ordered(f.Since) {
t.Errorf("%s floor %q does not parse", f.Name, f.Since)
}
}
}
// shippedFeatures is every floor the server admits against — the set each
// property below must hold for, so a new one cannot be added untested.
var shippedFeatures = []Feature{CertifiedHostKeys, DatagramExposures, Volumes}
// A refusal that only names a floor leaves the operator to guess why it
// matters, and the model on the other end of MCP with nothing to act on. Every
// shipped feature says what ignoring it costs.
func TestShippedFeaturesSayWhatIgnoringThemCosts(t *testing.T) {
for _, f := range shippedFeatures {
assert.NotEmpty(t, f.Consequence, "%s must say what happens if its floor is ignored", f.Name)
}
}