internal/server/api/network_api_test.go
Ref: Size: 11.0 KiB History
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// agentAdvertisesNetworks is the Hello of an agent started with --host-network:
// the names it will serve, held in the registry beside its version. Followed by
// the first report, because only a host that is online has told this server
// anything — the same predicate the capacity refusal is judged on.
func agentAdvertisesNetworks(hostID string, networks ...string) {
testReg.SetHostNetworks(hostID, networks)
agentReports(hostID)
}
// createVMOnNetwork posts a create asking for a named network, so a test reads
// as the placement it is really making.
func createVMOnNetwork(t *testing.T, ts *httptest.Server, hostID, name, network string) *http.Response {
t.Helper()
return do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
"host_id": hostID, "name": name, "network": network,
})
}
// TestCreateVMBridgedRefusedWhenHostSilent is where this refusal parts company
// with its siblings. Capacity and the certified-host-key check tolerate a host
// that has not spoken, because desired state is allowed to run ahead of a host
// that is merely offline. A network cannot: advertisement arrives only in the
// Hello, so silence is indistinguishable from an agent that predates the field
// — and that agent would put the guest on NAT without a word, which is the one
// outcome this design forbids.
func TestCreateVMBridgedRefusedWhenHostSilent(t *testing.T) {
ts, st, _ := testServer(t)
out := enroll(t, ts) // a Hello and nothing since: online is false
resp := createVMOnNetwork(t, ts, out["host_id"], "bridged", "lan")
require.Equal(t, 409, resp.StatusCode)
msg := bodyOf(t, resp)
assert.Contains(t, msg, `network "lan"`, "the refusal must name the network asked for")
assert.Contains(t, msg, `host "host-a" (`+out["host_id"]+")", "and the host it was asked of")
assert.Contains(t, msg, "it is not currently reporting, so its networks cannot be confirmed",
"a silent host's networks are unknown, not absent")
assert.Contains(t, msg, "create without a network", "the refusal must name a way out")
vms, err := st.ListVMs()
require.NoError(t, err)
assert.Empty(t, vms, "a refused create must leave no row behind")
}
// TestCreateVMBridgedRefusedWhenNotAdvertised: the host is right there and
// serving, and what it serves is not this. Both shapes of "no" have to be
// actionable — an agent configured with no networks at all needs the flag, one
// serving other names needs to be told which.
func TestCreateVMBridgedRefusedWhenNotAdvertised(t *testing.T) {
t.Run("advertises nothing", func(t *testing.T) {
ts, st, _ := testServer(t)
out := enroll(t, ts)
agentAdvertisesNetworks(out["host_id"])
resp := createVMOnNetwork(t, ts, out["host_id"], "bridged", "lan")
require.Equal(t, 409, resp.StatusCode)
msg := bodyOf(t, resp)
assert.Contains(t, msg, `network "lan"`)
assert.Contains(t, msg, "configured with no named networks")
assert.Contains(t, msg, "--host-network lan=<bridge>", "the remedy is the flag that would serve it")
vms, err := st.ListVMs()
require.NoError(t, err)
assert.Empty(t, vms)
})
t.Run("advertises another name", func(t *testing.T) {
ts, _, _ := testServer(t)
out := enroll(t, ts)
agentAdvertisesNetworks(out["host_id"], "lab", "dmz")
resp := createVMOnNetwork(t, ts, out["host_id"], "bridged", "lan")
require.Equal(t, 409, resp.StatusCode)
msg := bodyOf(t, resp)
assert.Contains(t, msg, `network "lan"`)
assert.Contains(t, msg, "it advertises lab, dmz", "what the host does serve is half the answer")
assert.Contains(t, msg, "--host-network lan=<bridge>")
})
}
// TestCreateVMBridgedRefusedOnIncapableOS is the refusal for a host whose OS
// cannot serve a named network at all, distinct from a host that merely isn't
// advertising one: no --host-network remedy exists for a Mac, so the message
// must not offer it, and must instead point at what does exist — a Linux
// host. This fires even before the host has spoken (see noNetworkRefusal's
// os parameter), because an OS, once enrolled, does not change out from under
// a host the way its advertised networks do.
func TestCreateVMBridgedRefusedOnIncapableOS(t *testing.T) {
ts, st, _ := testServer(t)
out := enrollSilentOS(t, ts, "darwin")
resp := createVMOnNetwork(t, ts, out["host_id"], "bridged", "lan")
require.Equal(t, 409, resp.StatusCode)
msg := bodyOf(t, resp)
assert.Contains(t, msg, `network "lan"`)
assert.Contains(t, msg, "darwin", "the refusal must name the incapable OS")
assert.Contains(t, msg, "Linux host", "the remedy is a different host, not a flag")
assert.NotContains(t, msg, "--host-network", "no flag can make a Mac serve a named network")
vms, err := st.ListVMs()
require.NoError(t, err)
assert.Empty(t, vms, "a refused create must leave no row behind")
}
// TestCreateVMBridgedNotRefusedOnIncapableOSWhenOSUnknown pins the empty-os
// fallthrough: an enroll request that omitted the OS field is not the same
// claim as a host known to be incapable, so it must get the ordinary
// not-currently-reporting text, not the OS-incapable one.
func TestCreateVMBridgedNotRefusedOnIncapableOSWhenOSUnknown(t *testing.T) {
ts, _, _ := testServer(t)
out := enrollSilentOS(t, ts, "")
resp := createVMOnNetwork(t, ts, out["host_id"], "bridged", "lan")
require.Equal(t, 409, resp.StatusCode)
msg := bodyOf(t, resp)
assert.Contains(t, msg, "it is not currently reporting", "unknown OS must fall through to the silent-host text")
assert.NotContains(t, msg, "cannot serve named networks", "an unset OS is not a known-incapable one")
}
// TestCreateVMBridgedAcceptedWhenAdvertised is the accepting half: a name the
// host advertises is placed, and the name lands on the row — the record the
// snapshot reads to tell the agent which underlay to build.
func TestCreateVMBridgedAcceptedWhenAdvertised(t *testing.T) {
ts, st, _ := testServer(t)
out := enroll(t, ts)
agentAdvertisesNetworks(out["host_id"], "lan")
resp := createVMOnNetwork(t, ts, out["host_id"], "bridged", "lan")
require.Equal(t, 201, resp.StatusCode)
var created map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
vm, err := st.GetVM(created["id"])
require.NoError(t, err)
assert.Equal(t, "lan", vm.Network)
}
// TestCreateVMWithoutANetworkIsNeverJudged: the NAT default is what nearly
// every create asks for, and admission must not have made it conditional on a
// host saying anything. A silent host still takes a NAT create.
func TestCreateVMWithoutANetworkIsNeverJudged(t *testing.T) {
ts, st, _ := testServer(t)
out := enrollSilent(t, ts)
resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
"host_id": out["host_id"], "name": "natted",
})
require.Equal(t, 201, resp.StatusCode)
var created map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
vm, err := st.GetVM(created["id"])
require.NoError(t, err)
assert.Equal(t, "", vm.Network, "an unnamed network is the NAT underlay")
}
// TestCreateVMInvalidNetworkName: a name that cannot be a network name is a
// malformed request, judged before any host is consulted — the grammar lives in
// names.IsNetworkName, which the agent's flag parser refuses with too.
func TestCreateVMInvalidNetworkName(t *testing.T) {
ts, _, _ := testServer(t)
out := enroll(t, ts)
agentAdvertisesNetworks(out["host_id"], "lan")
for _, bad := range []string{"NAT!", "nat", "-lan", "lan_1"} {
resp := createVMOnNetwork(t, ts, out["host_id"], "bridged", bad)
require.Equal(t, 400, resp.StatusCode, "network %q must be refused as malformed", bad)
assert.Contains(t, bodyOf(t, resp), "invalid network")
}
}
// TestHostViewServesAdvertisedNetworks: the console cannot offer a network the
// operator did not configure, so what a host advertises has to reach the host
// view — and a host that has said nothing must serve an empty set rather than
// the last thing it said.
func TestHostViewServesAdvertisedNetworks(t *testing.T) {
ts, _, _ := testServer(t)
out := enroll(t, ts)
agentAdvertisesNetworks(out["host_id"], "lan", "lab")
resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
require.Equal(t, 200, resp.StatusCode)
var hosts []struct {
ID string `json:"id"`
HostNetworks []string `json:"host_networks"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&hosts))
require.Len(t, hosts, 1)
assert.Equal(t, out["host_id"], hosts[0].ID)
assert.Equal(t, []string{"lan", "lab"}, hosts[0].HostNetworks)
}
// TestHostViewServesEmptyNetworksNotNull asserts on the response bytes rather
// than a decoded struct because the consumer is TypeScript: the contract is the
// literal JSON the browser receives, so that is where it has to be checked.
// host_networks is declared non-nullable and the console indexes it without a
// guard, so a host with nothing to advertise has to serve [] — the empty answer
// — and never null. Both silences produce it: a host that has never connected,
// and a connected host started without a single --host-network.
func TestHostViewServesEmptyNetworksNotNull(t *testing.T) {
for _, tc := range []struct {
name string
enrolls func(t *testing.T, ts *httptest.Server)
}{
{"never connected", func(t *testing.T, ts *httptest.Server) {
enrollSilent(t, ts)
}},
{"connected, advertising nothing", func(t *testing.T, ts *httptest.Server) {
out := enroll(t, ts)
agentAdvertisesNetworks(out["host_id"])
}},
} {
t.Run(tc.name, func(t *testing.T) {
ts, _, _ := testServer(t)
tc.enrolls(t, ts)
resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
require.Equal(t, 200, resp.StatusCode)
body := bodyOf(t, resp)
assert.Contains(t, body, `"host_networks":[]`, "the empty set is a list, not a null")
assert.NotContains(t, body, `"host_networks":null`)
})
}
}
// TestCreateVMBridgedRefusedWhenAdvertiserIsNotReporting is the state between
// the two refusals above: the registry still holds what this host advertised,
// but the host stopped reporting. TestCreateVMBridgedRefusedWhenHostSilent does
// not reach it — there the network list is empty too, so the name-match clause
// refuses on its own and the online clause is never consulted.
func TestCreateVMBridgedRefusedWhenAdvertiserIsNotReporting(t *testing.T) {
ts, st, _ := testServer(t)
out := enroll(t, ts)
// The advertisement without the report: exactly what the registry holds
// after a host that served "lan" goes away.
testReg.SetHostNetworks(out["host_id"], []string{"lan"})
resp := createVMOnNetwork(t, ts, out["host_id"], "bridged", "lan")
require.Equal(t, 409, resp.StatusCode,
"a cached advertisement from a host that is not reporting must not be trusted: the host may have been reconfigured or rebuilt since, and a guest placed on a network it no longer serves lands on NAT without a word")
assert.Contains(t, bodyOf(t, resp), "it is not currently reporting, so its networks cannot be confirmed",
"the refusal must blame the silence, not the network name — the host did advertise it")
vms, err := st.ListVMs()
require.NoError(t, err)
assert.Empty(t, vms, "a refused create must leave no row behind")
}