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