internal/server/store/trustedcas_test.go
Ref: Size: 9.1 KiB History
package store
import (
"crypto/ed25519"
"crypto/rand"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
// TestTrustedCAsRoundtrip pins that a VM's frozen CA set survives the store
// whole — label, fingerprint and key line, in the order it was given, since
// that is the order the guest's TrustedUserCAKeys file ends up in.
func TestTrustedCAsRoundtrip(t *testing.T) {
s := newStore(t)
h := enrollHost(t, s)
want := []TrustedCA{
{Label: "laptop", Fingerprint: "SHA256:aaa", AuthorizedKey: "ssh-ed25519 AAAALAPTOP"},
{Label: "", Fingerprint: "SHA256:bbb", AuthorizedKey: "ssh-ed25519 AAAACI"},
}
require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "web-1", ImageURL: "u",
ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
TrustedCAs: want}))
got, err := s.GetVM("vm1")
require.NoError(t, err)
assert.Equal(t, want, got.TrustedCAs)
}
// TestTrustedCAsUnrecordedIsNilNotEmpty pins the distinction the whole feature
// turns on. A VM created with no record reads back as nil — "we did not write
// this down" — and NOT as an empty set, which would claim the guest trusts no
// CA at all. Every reader (the console's empty state, the snapshot's fallback)
// branches on exactly this.
func TestTrustedCAsUnrecordedIsNilNotEmpty(t *testing.T) {
s := newStore(t)
h := enrollHost(t, s)
require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "web-1", ImageURL: "u",
ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
got, err := s.GetVM("vm1")
require.NoError(t, err)
assert.Nil(t, got.TrustedCAs)
}
// legacyDB builds the database a pre-freeze server leaves behind: one VM whose
// trusted_cas is NULL — nothing written down about what it trusts — plus
// whatever the tenant has registered by then, which register may add. Reopening
// the returned path runs the real migration.
func legacyDB(t *testing.T, register func(*Store)) string {
t.Helper()
path := filepath.Join(t.TempDir(), "eitri.db")
s, err := Open(path, "10.77.0.0/16")
require.NoError(t, err)
_, err = s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
require.NoError(t, err)
h := enrollHost(t, s)
if register != nil {
register(s)
}
// No TrustedCAs: CreateVM writes NULL, which is exactly the row a server
// from before the column existed wrote.
require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "web-1", ImageURL: "u",
ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
require.NoError(t, s.Close())
return path
}
func reopen(t *testing.T, path string) *Store {
t.Helper()
s, err := Open(path, "10.77.0.0/16")
require.NoError(t, err)
t.Cleanup(func() { s.Close() })
return s
}
// testCA mints a real ed25519 CA line, because the record the backfill writes
// carries a fingerprint derived from the key — an invented string cannot pin it.
func testCA(t *testing.T) (line, fingerprint string) {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
signer, err := ssh.NewSignerFromKey(priv)
require.NoError(t, err)
pub := signer.PublicKey()
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub))), ssh.FingerprintSHA256(pub)
}
// TestOpenFreezesAnUnrecordedRowOntoTheTenantsCurrentSet is the upgrade half of
// the freeze. A row that recorded nothing was served the tenant's LIVE set on
// every push, forever — so "a guest trusts the set it was created against" was
// simply false for it, and the console could never call its trust stale, having
// nothing to compare against. The upgrade is the last moment that VM's trust can
// still be written down, so it is written down here: the set it was already
// being served, now a record.
func TestOpenFreezesAnUnrecordedRowOntoTheTenantsCurrentSet(t *testing.T) {
caLine, caFP := testCA(t)
path := legacyDB(t, func(s *Store) {
require.NoError(t, s.AddTenantUserCA(testTenant, caLine, "tenant", "laptop", "admin"))
})
s := reopen(t, path)
got, err := s.GetVM("vm1")
require.NoError(t, err)
assert.Equal(t, []TrustedCA{{Label: "laptop", Fingerprint: caFP, AuthorizedKey: caLine}},
got.TrustedCAs, "the row must record the set it was being served")
assert.Equal(t, "web-1", got.Name, "the rest of the row survives the migration")
// The column itself, not just the round-trip: the backfill writes the JSON
// the create path writes, so no reader can tell the two apart.
var raw string
require.NoError(t, s.db.QueryRow(`SELECT trusted_cas FROM vms WHERE id='vm1'`).Scan(&raw))
assert.JSONEq(t,
`[{"label":"laptop","fingerprint":"`+caFP+`","authorized_key":"`+caLine+`"}]`, raw)
}
// TestOpenFreezesARowThatPredatesTheColumn runs the same freeze through the
// other legacy shape: a database whose vms table has no trusted_cas column at
// all, so ensureColumn adds it back as NULL first and the backfill fills it in
// the same Open.
func TestOpenFreezesARowThatPredatesTheColumn(t *testing.T) {
caLine, caFP := testCA(t)
path := legacyDB(t, func(s *Store) {
require.NoError(t, s.AddTenantUserCA(testTenant, caLine, "tenant", "laptop", "admin"))
})
pre := reopen(t, path)
_, err := pre.db.Exec(`ALTER TABLE vms DROP COLUMN trusted_cas`)
require.NoError(t, err)
require.NoError(t, pre.Close())
got, err := reopen(t, path).GetVM("vm1")
require.NoError(t, err)
assert.Equal(t, []TrustedCA{{Label: "laptop", Fingerprint: caFP, AuthorizedKey: caLine}},
got.TrustedCAs)
}
// TestOpenFreezesAnEmptySetForATenantWithNoCAs pins the honest answer for the
// rare tenant that has registered nothing: an empty set — "this guest trusts no
// CA" — and not NULL. It is what the live fallback was already serving such a
// VM, and create refuses a tenant with no CA, so only a tenant that removed its
// last CA afterwards can be here.
func TestOpenFreezesAnEmptySetForATenantWithNoCAs(t *testing.T) {
s := reopen(t, legacyDB(t, nil))
got, err := s.GetVM("vm1")
require.NoError(t, err)
assert.NotNil(t, got.TrustedCAs, "an unrecorded row must not stay unrecorded")
assert.Empty(t, got.TrustedCAs, "a tenant with no CAs freezes an empty set")
var raw string
require.NoError(t, s.db.QueryRow(`SELECT trusted_cas FROM vms WHERE id='vm1'`).Scan(&raw))
assert.Equal(t, "[]", raw)
}
// TestOpenLeavesAnAlreadyFrozenRowAlone is what keeps the migration from
// becoming the live-set read it replaces: a row that has a set never gets a
// newer one, however often the server restarts and whatever the tenant has
// registered since.
func TestOpenLeavesAnAlreadyFrozenRowAlone(t *testing.T) {
first, fp := testCA(t)
path := legacyDB(t, func(s *Store) {
require.NoError(t, s.AddTenantUserCA(testTenant, first, "tenant", "laptop", "admin"))
})
require.NoError(t, reopen(t, path).Close()) // freezes
// A second CA arrives, and the server restarts again.
again := reopen(t, path)
second, _ := testCA(t)
require.NoError(t, again.AddTenantUserCA(testTenant, second, "tenant", "ci", "admin"))
require.NoError(t, again.Close())
got, err := reopen(t, path).GetVM("vm1")
require.NoError(t, err)
assert.Equal(t, []TrustedCA{{Label: "laptop", Fingerprint: fp, AuthorizedKey: first}},
got.TrustedCAs, "a frozen set must not be refreshed by a later Open")
}
// TestOpenFreezesARowLeftByARolledBackServer is why the backfill runs on every
// Open rather than once ever. The previous release writes no trusted_cas at all,
// so a plane that rolls back and forward again mints fresh unrecorded rows; a
// one-shot marker would leave exactly those on the live set forever, which is
// the bug this migration closes.
func TestOpenFreezesARowLeftByARolledBackServer(t *testing.T) {
caLine, caFP := testCA(t)
path := legacyDB(t, func(s *Store) {
require.NoError(t, s.AddTenantUserCA(testTenant, caLine, "tenant", "laptop", "admin"))
})
s := reopen(t, path)
// What the older server's create leaves behind, after this one has run.
_, err := s.db.Exec(`UPDATE vms SET trusted_cas=NULL WHERE id='vm1'`)
require.NoError(t, err)
require.NoError(t, s.Close())
got, err := reopen(t, path).GetVM("vm1")
require.NoError(t, err)
assert.Equal(t, []TrustedCA{{Label: "laptop", Fingerprint: caFP, AuthorizedKey: caLine}},
got.TrustedCAs, "a row minted by a rolled-back server must still be frozen")
}
// TestOpenFreezesEachTenantsOwnSet: the backfill reads and caches per tenant,
// which is where a fleet-wide read would hand one tenant's CA to another
// tenant's guest — the failure the create-side refusal is tested against for
// the same reason.
func TestOpenFreezesEachTenantsOwnSet(t *testing.T) {
mine, mineFP := testCA(t)
theirs, _ := testCA(t)
path := legacyDB(t, func(s *Store) {
other, err := s.CreateTenantForIdentity("https://test-issuer", "other-subject", "other@test.local")
require.NoError(t, err)
require.NoError(t, s.AddTenantUserCA(testTenant, mine, "tenant", "laptop", "admin"))
require.NoError(t, s.AddTenantUserCA(other.ID, theirs, "tenant", "someone-elses", "admin"))
})
got, err := reopen(t, path).GetVM("vm1")
require.NoError(t, err)
assert.Equal(t, []TrustedCA{{Label: "laptop", Fingerprint: mineFP, AuthorizedKey: mine}},
got.TrustedCAs, "another tenant's CA must not be frozen onto this tenant's guest")
}