a73x

internal/server/api/trustedcas_test.go

Ref:   Size: 5.1 KiB   History

package api

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

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

// createVMFor creates a VM on hostID as the holder of pat and returns its id.
func createVMFor(t *testing.T, ts *httptest.Server, pat, hostID string) string {
	t.Helper()
	resp := do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID})
	require.Equal(t, http.StatusCreated, resp.StatusCode)
	var out struct {
		ID string `json:"id"`
	}
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
	return out.ID
}

// getVM reads one VM back off the list endpoint as the holder of pat — the
// same route the console renders every VM from, so what this sees is what the
// page sees.
func getVM(t *testing.T, ts *httptest.Server, pat, id string) types.VM {
	t.Helper()
	resp := do(t, "GET", ts.URL+"/api/v1/vms", pat, nil)
	require.Equal(t, http.StatusOK, resp.StatusCode)
	var vms []types.VM
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&vms))
	for _, vm := range vms {
		if vm.ID == id {
			return vm
		}
	}
	t.Fatalf("vm %s absent from the list", id)
	return types.VM{}
}

// uploadCA registers a freshly generated user CA under label and returns its
// canonical authorized_keys line.
func uploadCA(t *testing.T, ts *httptest.Server, pat, label string) string {
	t.Helper()
	_, signer, err := sshca.GenerateHostKey()
	require.NoError(t, err)
	line := sshca.AuthorizedKeyLine(signer.PublicKey())
	resp := do(t, "POST", ts.URL+"/api/v1/user-cas", pat,
		map[string]any{"public_key": line, "label": label})
	require.Equal(t, http.StatusCreated, resp.StatusCode)
	return line
}

// TestCreatedVMNamesTheCAsItTrusts pins the fact the VM page exists to show:
// the set is on the object, by the label the tenant gave it, and it is the set
// that was registered when the VM was made.
func TestCreatedVMNamesTheCAsItTrusts(t *testing.T) {
	ts, st, _, _, _ := newServer(t)
	pat, hostID := caLessTenant(t, ts, st, "trusts")
	uploadCA(t, ts, pat, "laptop")

	vm := getVM(t, ts, pat, createVMFor(t, ts, pat, hostID))

	require.NotNil(t, vm.TrustedCAs, "a VM created now records what it trusts")
	require.Len(t, *vm.TrustedCAs, 1)
	assert.Equal(t, "laptop", (*vm.TrustedCAs)[0].Label)
	assert.NotEmpty(t, (*vm.TrustedCAs)[0].Fingerprint, "the fingerprint is what an operator matches by eye")
}

// TestALaterCADoesNotJoinAnExistingVMsTrust is the create-time freeze as the
// API tells it, and the same property the snapshot test pins one layer down.
// If this ever fails, the 409 that refuses a CA-less tenant is telling users
// something untrue about their own fleet.
func TestALaterCADoesNotJoinAnExistingVMsTrust(t *testing.T) {
	ts, st, _, _, _ := newServer(t)
	pat, hostID := caLessTenant(t, ts, st, "frozen")
	uploadCA(t, ts, pat, "laptop")

	id := createVMFor(t, ts, pat, hostID)
	uploadCA(t, ts, pat, "ci") // registered AFTER the VM exists

	vm := getVM(t, ts, pat, id)
	require.NotNil(t, vm.TrustedCAs)
	require.Len(t, *vm.TrustedCAs, 1, "the guest's trust was fixed when it was created")
	assert.Equal(t, "laptop", (*vm.TrustedCAs)[0].Label)

	// And the fuller set does reach the next VM, so the freeze is per-VM rather
	// than the tenant's set having simply stopped growing.
	later := getVM(t, ts, pat, createVMFor(t, ts, pat, hostID))
	require.NotNil(t, later.TrustedCAs)
	assert.Len(t, *later.TrustedCAs, 2)
}

// TestTrustedCAsCarryNoKeyMaterial pins that the wire shows the two facts a
// reader needs and not the CA lines themselves. They are public keys, so this
// is not a secrecy boundary — it is the object answering the question it was
// asked instead of shipping the agent's copy to every console.
func TestTrustedCAsCarryNoKeyMaterial(t *testing.T) {
	ts, st, _, _, _ := newServer(t)
	pat, hostID := caLessTenant(t, ts, st, "nomaterial")
	line := uploadCA(t, ts, pat, "laptop")
	createVMFor(t, ts, pat, hostID)

	resp := do(t, "GET", ts.URL+"/api/v1/vms", pat, nil)
	require.Equal(t, http.StatusOK, resp.StatusCode)
	assert.NotContains(t, bodyText(t, resp), line,
		"the VM object names its CAs; it does not carry their key lines")
}

// TestTrustedCAsDoNotCrossTenants pins the isolation. The set rides a
// tenant-scoped object, so the leak this guards against would be a server-side
// mix-up rather than a missing authz check — one tenant's CA labels and
// fingerprints appearing on another tenant's guest.
func TestTrustedCAsDoNotCrossTenants(t *testing.T) {
	ts, st, _, _, _ := newServer(t)
	patA, hostA := caLessTenant(t, ts, st, "tenant-a")
	patB, hostB := caLessTenant(t, ts, st, "tenant-b")
	uploadCA(t, ts, patA, "a-laptop")
	uploadCA(t, ts, patB, "b-laptop")

	vmA := getVM(t, ts, patA, createVMFor(t, ts, patA, hostA))
	vmB := getVM(t, ts, patB, createVMFor(t, ts, patB, hostB))

	require.NotNil(t, vmA.TrustedCAs)
	require.NotNil(t, vmB.TrustedCAs)
	require.Len(t, *vmA.TrustedCAs, 1)
	require.Len(t, *vmB.TrustedCAs, 1)
	assert.Equal(t, "a-laptop", (*vmA.TrustedCAs)[0].Label)
	assert.Equal(t, "b-laptop", (*vmB.TrustedCAs)[0].Label)
	assert.NotEqual(t, (*vmA.TrustedCAs)[0].Fingerprint, (*vmB.TrustedCAs)[0].Fingerprint)
}