a73x

internal/server/api/upgrade_test.go

Ref:   Size: 12.3 KiB   History

package api

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/server/api/types"
	"github.com/a73x/eitri/internal/server/registry"
	"github.com/a73x/eitri/internal/server/release"
	"github.com/a73x/eitri/internal/version"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// fakeRelease is a ReleaseSource test double that always returns the same
// manifest (or ok=false when the manifest hasn't been "fetched").
type fakeRelease struct {
	m  release.Manifest
	ok bool
}

func (f fakeRelease) Latest() (release.Manifest, bool) { return f.m, f.ok }

// fakeUpgrader is an AgentUpgrader test double that captures the last offer.
// age is what PendingAgentUpgrade reports for it, so a test can stand an offer
// for as long as it likes without waiting.
type fakeUpgrader struct {
	host, version, url, sha string
	age                     time.Duration
}

func (f *fakeUpgrader) OfferAgentUpgrade(hostID, version, url, sha256 string) {
	f.host, f.version, f.url, f.sha = hostID, version, url, sha256
}

func (f *fakeUpgrader) ClearAgentUpgrade(hostID string) {
	if f.host == hostID {
		f.host, f.version, f.url, f.sha = "", "", "", ""
	}
}

func (f *fakeUpgrader) PendingAgentUpgrade(hostID string) (string, time.Duration, bool) {
	if f.host != hostID || f.host == "" {
		return "", 0, false
	}
	return f.version, f.age, true
}

// upgradeManifest is the happy-path manifest: a newer release with an
// eitri-agent artifact for linux/amd64 (matching the enrolled test host).
func upgradeManifest() release.Manifest {
	return release.Manifest{Version: "v0.0.2", Artifacts: map[string]map[string]release.Artifact{
		"eitri-agent": {"linux/amd64": {URL: "https://eitri.sh/dl/v0.0.2/eitri-agent_v0.0.2_linux_amd64.tar.gz", SHA256: "abcd"}},
	}}
}

func TestUpgradeAgentEndpoint(t *testing.T) {
	t.Run("happy path", func(t *testing.T) {
		ts, _, _, reg, a := newServer(t)
		out := enroll(t, ts)
		hostID := out["host_id"]
		reg.SetAgentVersion(hostID, "v0.0.1")
		reg.UpdateReport(hostID, registry.Report{})

		a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
		up := &fakeUpgrader{}
		a.SetAgentUpgrader(up)

		resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
		require.Equal(t, http.StatusAccepted, resp.StatusCode)

		assert.Equal(t, hostID, up.host)
		assert.Equal(t, "v0.0.2", up.version)
		assert.Equal(t, "https://eitri.sh/dl/v0.0.2/eitri-agent_v0.0.2_linux_amd64.tar.gz", up.url)
		assert.Equal(t, "abcd", up.sha)

		auditResp := do(t, "GET", ts.URL+"/api/v1/audit", testPAT, nil)
		require.Equal(t, http.StatusOK, auditResp.StatusCode)
		var rows []types.AuditEvent
		require.NoError(t, json.NewDecoder(auditResp.Body).Decode(&rows))
		var found bool
		for _, row := range rows {
			if row.Action == "host.agent.upgrade" {
				found = true
				assert.Contains(t, string(row.Detail), hostID)
			}
		}
		assert.True(t, found, "expected a host.agent.upgrade audit row")
	})

	t.Run("release source not wired", func(t *testing.T) {
		ts, _, _, reg, a := newServer(t)
		out := enroll(t, ts)
		hostID := out["host_id"]
		reg.SetAgentVersion(hostID, "v0.0.1")
		reg.UpdateReport(hostID, registry.Report{})
		a.SetAgentUpgrader(&fakeUpgrader{})
		// a.release left nil.

		resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
		assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
	})

	t.Run("manifest not fetched", func(t *testing.T) {
		ts, _, _, reg, a := newServer(t)
		out := enroll(t, ts)
		hostID := out["host_id"]
		reg.SetAgentVersion(hostID, "v0.0.1")
		reg.UpdateReport(hostID, registry.Report{})
		a.SetReleaseSource(fakeRelease{ok: false})
		a.SetAgentUpgrader(&fakeUpgrader{})

		resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
		assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
	})

	t.Run("unknown host id", func(t *testing.T) {
		ts, _, _, _, a := newServer(t)
		a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
		a.SetAgentUpgrader(&fakeUpgrader{})

		resp := do(t, "POST", ts.URL+"/api/v1/hosts/does-not-exist/upgrade-agent", testPAT, nil)
		assert.Equal(t, http.StatusNotFound, resp.StatusCode)
	})

	t.Run("host offline", func(t *testing.T) {
		ts, _, _, _, a := newServer(t)
		out := enroll(t, ts)
		hostID := out["host_id"]
		// No registry report at all: never connected, so offline.
		a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
		a.SetAgentUpgrader(&fakeUpgrader{})

		resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
		assert.Equal(t, http.StatusConflict, resp.StatusCode)
	})

	t.Run("agent already at latest", func(t *testing.T) {
		ts, _, _, reg, a := newServer(t)
		out := enroll(t, ts)
		hostID := out["host_id"]
		reg.SetAgentVersion(hostID, "v0.0.2")
		reg.UpdateReport(hostID, registry.Report{})
		a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
		a.SetAgentUpgrader(&fakeUpgrader{})

		resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
		assert.Equal(t, http.StatusConflict, resp.StatusCode)
	})

	t.Run("no artifact for host os/arch", func(t *testing.T) {
		ts, _, _, reg, a := newServer(t)
		out := enroll(t, ts)
		hostID := out["host_id"]
		reg.SetAgentVersion(hostID, "v0.0.1")
		reg.UpdateReport(hostID, registry.Report{})
		a.SetReleaseSource(fakeRelease{ok: true, m: release.Manifest{
			Version:   "v0.0.2",
			Artifacts: map[string]map[string]release.Artifact{},
		}})
		a.SetAgentUpgrader(&fakeUpgrader{})

		resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
		assert.Equal(t, http.StatusConflict, resp.StatusCode)
	})
}

// TestHostResponseAgentUpdateAvailable pins the read-side computation in
// buildHostResponses: an online host whose reported AgentVersion trails the
// latest known release surfaces agent_version and a true
// agent_update_available; with no release source wired (or an unfetched
// manifest) the flag stays false even though the version still trails.
func TestHostResponseAgentUpdateAvailable(t *testing.T) {
	t.Run("online + behind + release known -> true", func(t *testing.T) {
		ts, _, _, reg, a := newServer(t)
		out := enroll(t, ts)
		hostID := out["host_id"]
		reg.SetAgentVersion(hostID, "v0.0.1")
		reg.UpdateReport(hostID, registry.Report{})
		a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})

		resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
		require.Equal(t, http.StatusOK, resp.StatusCode)
		items := decodeJSONKeys(t, resp)
		require.Len(t, items, 1)
		assert.Equal(t, "v0.0.1", items[0]["agent_version"])
		assert.Equal(t, true, items[0]["agent_update_available"])
	})

	t.Run("release source unwired -> false", func(t *testing.T) {
		ts, _, _, reg, _ := newServer(t)
		out := enroll(t, ts)
		hostID := out["host_id"]
		reg.SetAgentVersion(hostID, "v0.0.1")
		reg.UpdateReport(hostID, registry.Report{})
		// a.release left nil.

		resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
		require.Equal(t, http.StatusOK, resp.StatusCode)
		items := decodeJSONKeys(t, resp)
		require.Len(t, items, 1)
		assert.Equal(t, "v0.0.1", items[0]["agent_version"])
		assert.Equal(t, false, items[0]["agent_update_available"])
	})

	t.Run("manifest not fetched -> false", func(t *testing.T) {
		ts, _, _, reg, a := newServer(t)
		out := enroll(t, ts)
		hostID := out["host_id"]
		reg.SetAgentVersion(hostID, "v0.0.1")
		reg.UpdateReport(hostID, registry.Report{})
		a.SetReleaseSource(fakeRelease{ok: false})

		resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
		require.Equal(t, http.StatusOK, resp.StatusCode)
		items := decodeJSONKeys(t, resp)
		require.Len(t, items, 1)
		assert.Equal(t, false, items[0]["agent_update_available"])
	})
}

// TestHostResponsePendingUpgrade pins that an offer is visible while it stands.
// Before this the click was silent: the offer rode the next snapshot and
// nothing said so, which reads exactly like a button that did nothing.
func TestHostResponsePendingUpgrade(t *testing.T) {
	ts, _, _, reg, a := newServer(t)
	out := enroll(t, ts)
	hostID := out["host_id"]
	reg.SetAgentVersion(hostID, "v0.0.1")
	reg.UpdateReport(hostID, registry.Report{})
	a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
	up := &fakeUpgrader{}
	a.SetAgentUpgrader(up)

	hosts := func() map[string]any {
		resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
		require.Equal(t, http.StatusOK, resp.StatusCode)
		items := decodeJSONKeys(t, resp)
		require.Len(t, items, 1)
		return items[0]
	}

	assert.Nil(t, hosts()["pending_upgrade"], "no offer, nothing pending")

	resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
	require.Equal(t, http.StatusAccepted, resp.StatusCode)

	up.age = 90 * time.Second
	pending, ok := hosts()["pending_upgrade"].(map[string]any)
	require.True(t, ok, "an outstanding offer is on the wire")
	assert.Equal(t, "v0.0.2", pending["version"])
	assert.Equal(t, float64(90), pending["age_s"])

	// The agent takes it: the offer converges and the pending state goes away
	// on its own, without the console having to decide when to stop showing it.
	up.ClearAgentUpgrade(hostID)
	assert.Nil(t, hosts()["pending_upgrade"], "a converged offer leaves nothing pending")
}

// TestMarshalSnapshotCarriesVersions pins that the SSE snapshot payload
// itself (not just GET /hosts) carries the server's own build version and the
// latest known release version. marshalSnapshots is called directly (in-package)
// rather than driving the SSE endpoint, per the plan's guidance.
func TestMarshalSnapshotCarriesVersions(t *testing.T) {
	_, _, _, _, a := newServer(t)
	a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})

	payloads, err := a.marshalSnapshots([]string{testTenant})
	require.NoError(t, err)
	raw := payloads[testTenant]
	require.NotNil(t, raw)

	var snap types.StateSnapshot
	require.NoError(t, json.Unmarshal(raw, &snap))
	assert.Equal(t, version.Version, snap.ServerVersion)
	assert.Equal(t, "v0.0.2", snap.LatestVersion)
}

// refuseBelowFloor is the one admission shape every version-floored feature
// shares. judgeOffline is its only parameter: the shipped floors let a silent
// host through (a newer agent may pick the work up), volumes do not (an old
// agent would boot the guest bare and the data lands in the wrong place).
func TestRefuseBelowFloor(t *testing.T) {
	ts, _, _, reg, a := newServer(t)
	host := enroll(t, ts)
	hostID := host["host_id"]
	f := release.Feature{Name: "widgets", Since: "v0.0.5"}

	refuse := func(judgeOffline bool) (bool, string) {
		rec := httptest.NewRecorder()
		stop := a.refuseBelowFloor(rec, hostID, f, judgeOffline)
		return stop, rec.Body.String()
	}

	// Silent host: not judged unless asked to be.
	stop, _ := refuse(false)
	assert.False(t, stop, "a silent host is let through when judgeOffline is false")
	stop, body := refuse(true)
	assert.True(t, stop, "a silent host is refused when judgeOffline is true")
	assert.Contains(t, body, "widgets")
	assert.Contains(t, body, "not reporting")
	assert.Contains(t, body, "upgrade-agent")

	// Online, below floor: refused either way, naming version and floor.
	reg.SetAgentVersion(hostID, "v0.0.4")
	reg.UpdateReport(hostID, registry.Report{})
	stop, body = refuse(false)
	assert.True(t, stop)
	assert.Contains(t, body, "v0.0.4")
	assert.Contains(t, body, "v0.0.5")
	assert.Contains(t, body, "widgets")
	assert.Contains(t, body, "(v0.0.5). Upgrade", "a feature naming no consequence skips the clause")

	// Online, no version: the "reported no agent version" reading.
	reg.SetAgentVersion(hostID, "")
	reg.UpdateReport(hostID, registry.Report{})
	stop, body = refuse(false)
	assert.True(t, stop)
	assert.Contains(t, body, "has reported no agent version")

	// At floor: admitted.
	reg.SetAgentVersion(hostID, "v0.0.5")
	reg.UpdateReport(hostID, registry.Report{})
	stop, _ = refuse(true)
	assert.False(t, stop)

	// A feature that says what ignoring it costs puts that sentence in the
	// refusal, between what is known and the fix. Naming only the floor leaves
	// the operator — and the model reading this back out of MCP — to guess why
	// it matters.
	costly := release.Feature{
		Name:        "widgets",
		Since:       "v0.0.9",
		Consequence: "the widget lands in the wrong drawer",
	}
	rec := httptest.NewRecorder()
	assert.True(t, a.refuseBelowFloor(rec, hostID, costly, false))
	assert.Contains(t, rec.Body.String(),
		"widgets (v0.0.9): the widget lands in the wrong drawer. Upgrade that host's agent")
}