a73x

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)
	}
}