a73x

internal/server/api/exposures_test.go

Ref:   Size: 12.7 KiB   History

package api

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

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

// createTestVM places one VM on the enrolled host and returns its id.
func createTestVM(t *testing.T, ts *httptest.Server, hostID, name string) string {
	t.Helper()
	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": hostID, "name": name})
	require.Equal(t, 201, resp.StatusCode)
	var out map[string]string
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
	return out["id"]
}

// patForOtherTenant JIT-provisions a second tenant and mints a PAT for it, so
// a test can act as somebody else entirely.
func patForOtherTenant(t *testing.T, st *store.Store) string {
	t.Helper()
	tn, err := st.CreateTenantForIdentity("https://test-issuer", "other-subject", "other@test.local")
	require.NoError(t, err)
	secret, _, err := st.CreateAPIToken(tn.ID, "other", 0)
	require.NoError(t, err)
	return secret
}

func TestCreateExposureAllocatesAndLists(t *testing.T) {
	ts, _, _ := testServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
		map[string]any{"guest_port": 8080})
	require.Equal(t, 201, resp.StatusCode)
	var e map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&e))
	assert.Equal(t, float64(8080), e["guest_port"])
	assert.GreaterOrEqual(t, e["host_port"], float64(30000))
	assert.LessOrEqual(t, e["host_port"], float64(32767))
	assert.Equal(t, "tcp", e["protocol"])
	assert.Equal(t, "lan", e["scope"])
	assert.Equal(t, "pending", e["state"], "no agent has reported on it yet")
	assert.Equal(t, vmID, e["vm_id"])

	resp = do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, nil)
	require.Equal(t, 200, resp.StatusCode)
	var list []map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&list))
	require.Len(t, list, 1)
	assert.Equal(t, e["id"], list[0]["id"])
}

func TestCreateExposureHonoursAndRefusesHostPorts(t *testing.T) {
	ts, _, _ := testServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")
	otherID := createTestVM(t, ts, host["host_id"], "web-2")

	resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
		map[string]any{"guest_port": 8080, "host_port": 8443})
	require.Equal(t, 201, resp.StatusCode)

	// The same port on the same host is taken.
	resp = do(t, "POST", ts.URL+"/api/v1/vms/"+otherID+"/exposures", testPAT,
		map[string]any{"guest_port": 9090, "host_port": 8443})
	assert.Equal(t, 409, resp.StatusCode)
}

func TestCreateExposureDefaultsToTCPAndTakesUDP(t *testing.T) {
	ts, _, _ := testServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
		map[string]any{"guest_port": 53, "host_port": 8443, "protocol": "udp"})
	require.Equal(t, 201, resp.StatusCode)
	var udp map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&udp))
	assert.Equal(t, "udp", udp["protocol"])

	// The same host port carries one of each, and no more than one of either.
	resp = do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
		map[string]any{"guest_port": 53, "host_port": 8443})
	require.Equal(t, 201, resp.StatusCode)
	var tcp map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&tcp))
	assert.Equal(t, "tcp", tcp["protocol"], "a request that names no protocol means tcp")

	resp = do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
		map[string]any{"guest_port": 54, "host_port": 8443, "protocol": "udp"})
	assert.Equal(t, 409, resp.StatusCode)
}

// TestCreateExposureFloorsUDPBelowFirstDatagramAgent pins the one place a grant
// is refused for what the host runs rather than what was asked: a UDP exposure
// needs an agent that binds UDP. An older agent listens TCP unconditionally and
// reports the exposure active anyway, so the row would read published and carry
// no datagrams — refuse it here, where the host can still be upgraded. Only a
// connected, reporting host is judged, exactly as the certified-host-key create
// refusal judges its floor — both go through refuseBelowFloor.
func TestCreateExposureFloorsUDPBelowFirstDatagramAgent(t *testing.T) {
	ts, _, _, reg, _ := newServer(t)
	host := enroll(t, ts)
	hostID := host["host_id"]
	vmID := createTestVM(t, ts, hostID, "web-1")

	udp := func(guestPort int) *http.Response {
		return do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
			map[string]any{"guest_port": guestPort, "protocol": "udp"})
	}

	// enroll leaves the host known-but-silent (no report), the state of every
	// host for a moment after a server restart: nothing says what it runs, so —
	// with judgeOffline false — it is not judged and the UDP grant is taken.
	require.Equal(t, 201, udp(50).StatusCode, "a silent host is not floored")

	// An online agent below the datagram floor is refused, naming the version and
	// the endpoint that fixes it.
	reg.SetAgentVersion(hostID, "v0.0.4")
	reg.UpdateReport(hostID, registry.Report{})
	resp := udp(51)
	require.Equal(t, 409, resp.StatusCode)
	body, err := io.ReadAll(resp.Body)
	require.NoError(t, err)
	assert.Contains(t, string(body), release.FirstDatagramExposures, "the refusal names the floor version")
	assert.Contains(t, string(body), "upgrade-agent", "and the endpoint that fixes it")

	// A TCP grant to that same below-floor host is never floored.
	resp = do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
		map[string]any{"guest_port": 80})
	require.Equal(t, 201, resp.StatusCode, "a TCP grant is unaffected by the datagram floor")

	// At the floor, the UDP grant is taken.
	reg.SetAgentVersion(hostID, release.FirstDatagramExposures)
	reg.UpdateReport(hostID, registry.Report{})
	require.Equal(t, 201, udp(52).StatusCode, "an at-floor agent honors the protocol")
}

func TestCreateExposureRefusesAnotherProtocol(t *testing.T) {
	ts, _, _ := testServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	for _, proto := range []string{"sctp", "TCP", "udp4", "http"} {
		t.Run(proto, func(t *testing.T) {
			resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
				map[string]any{"guest_port": 8080, "protocol": proto})
			require.Equal(t, 400, resp.StatusCode)
			body, err := io.ReadAll(resp.Body)
			require.NoError(t, err)
			assert.Contains(t, string(body), `"tcp" or "udp"`, "a refusal names what it would take")
		})
	}
}

func TestCreateExposureValidatesPorts(t *testing.T) {
	ts, _, _ := testServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	for _, tc := range []struct {
		name string
		body map[string]any
	}{
		{"guest port zero", map[string]any{"guest_port": 0}},
		{"guest port too high", map[string]any{"guest_port": 65536}},
		{"guest port negative", map[string]any{"guest_port": -1}},
		{"privileged host port", map[string]any{"guest_port": 8080, "host_port": 443}},
		{"host port too high", map[string]any{"guest_port": 8080, "host_port": 65536}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, tc.body)
			assert.Equal(t, 400, resp.StatusCode)
		})
	}
}

func TestDeleteExposure(t *testing.T) {
	ts, _, _ := testServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
		map[string]any{"guest_port": 8080})
	require.Equal(t, 201, resp.StatusCode)
	var e map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&e))

	resp = do(t, "DELETE", ts.URL+"/api/v1/exposures/"+e["id"].(string), testPAT, nil)
	assert.Equal(t, http.StatusNoContent, resp.StatusCode)

	resp = do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, nil)
	var list []map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&list))
	assert.Empty(t, list)

	// A second delete is a 404 — an exposure is a grant, and it is gone.
	resp = do(t, "DELETE", ts.URL+"/api/v1/exposures/"+e["id"].(string), testPAT, nil)
	assert.Equal(t, 404, resp.StatusCode)
}

func TestExposureRoutesAreTenantScoped(t *testing.T) {
	ts, st, _ := testServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")
	resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
		map[string]any{"guest_port": 8080})
	require.Equal(t, 201, resp.StatusCode)
	var e map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&e))

	otherPAT := patForOtherTenant(t, st)

	// A foreign tenant answers exactly like a missing one — existence is not
	// leaked across tenants.
	assert.Equal(t, 404, do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", otherPAT, nil).StatusCode)
	assert.Equal(t, 404, do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", otherPAT,
		map[string]any{"guest_port": 9090}).StatusCode)
	assert.Equal(t, 404, do(t, "DELETE", ts.URL+"/api/v1/exposures/"+e["id"].(string), otherPAT, nil).StatusCode)
}

// TestExposureReportedStateAndAddressReachTheWire pins the fold from a host's
// live report onto the exposures it was asked to run: the state and reason the
// agent last said, and the address the host answers on. The second exposure —
// present in the store, absent from the report — pins that the fold matches by
// exposure id rather than by position, so a host reporting on one listener
// cannot colour another one's state.
func TestExposureReportedStateAndAddressReachTheWire(t *testing.T) {
	ts, st, _, reg, _ := newServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	newExposure := func(guestPort, hostPort int64) string {
		t.Helper()
		resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
			map[string]any{"guest_port": guestPort, "host_port": hostPort})
		require.Equal(t, 201, resp.StatusCode)
		var e map[string]any
		require.NoError(t, json.NewDecoder(resp.Body).Decode(&e))
		return e["id"].(string)
	}
	// Named host ports, so the list order (lowest host port first) is fixed.
	reported := newExposure(8080, 30080)
	newExposure(9090, 30081)

	require.NoError(t, st.RecordHostUplink(host["host_id"], "192.168.0.190"))
	reg.UpdateReport(host["host_id"], registry.Report{
		Exposures: []registry.ExposureStatus{{
			ID:     reported,
			State:  "failed",
			Reason: "listen tcp 0.0.0.0:30080: bind: address already in use",
		}},
	})

	resp := do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT, nil)
	require.Equal(t, 200, resp.StatusCode)
	var list []map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&list))
	require.Len(t, list, 2)

	assert.Equal(t, reported, list[0]["id"])
	assert.Equal(t, "failed", list[0]["state"])
	assert.Contains(t, list[0]["reason"], "address already in use")

	// The unreported one keeps its own state — nothing has said what the host
	// made of it.
	assert.Equal(t, "pending", list[1]["state"])
	assert.Equal(t, "", list[1]["reason"])

	// The address is the host's, so every exposure on it names the same place.
	for _, e := range list {
		assert.Equal(t, "192.168.0.190", e["host_addr"])
	}
}

// TestCreateExposureOnTombstonedVMIsNotFound pins that a VM on its way out
// takes no new grants: the store refuses, and the refusal reads as a missing
// VM rather than a server error.
func TestCreateExposureOnTombstonedVMIsNotFound(t *testing.T) {
	ts, st, _ := testServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	require.NoError(t, st.TombstoneVM(vmID))

	resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
		map[string]any{"guest_port": 8080})
	assert.Equal(t, 404, resp.StatusCode)
}

func TestExposureCreateAndDeleteAreAudited(t *testing.T) {
	ts, _, _ := testServer(t)
	host := enroll(t, ts)
	vmID := createTestVM(t, ts, host["host_id"], "web-1")

	resp := do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/exposures", testPAT,
		map[string]any{"guest_port": 8080})
	require.Equal(t, 201, resp.StatusCode)
	var e map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&e))
	require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/exposures/"+e["id"].(string), testPAT, nil).StatusCode)

	// Both rows key the VM as vm_id, so they land in that VM's own timeline.
	resp = do(t, "GET", ts.URL+"/api/v1/vms/"+vmID+"/events", testPAT, nil)
	require.Equal(t, 200, resp.StatusCode)
	var events []map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&events))
	actions := map[string]bool{}
	for _, ev := range events {
		actions[ev["action"].(string)] = true
	}
	assert.True(t, actions["exposure.create"], "events: %v", actions)
	assert.True(t, actions["exposure.delete"], "events: %v", actions)
}