a73x

internal/server/store/store_test.go

Ref:   Size: 60.3 KiB   History

package store

import (
	"bytes"
	"context"
	"database/sql"
	"log/slog"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/random"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestPing(t *testing.T) {
	s := newStore(t)
	require.NoError(t, s.Ping(context.Background()))
	// After Close the handle is dead — Ping must surface that (readyz then 503s).
	require.NoError(t, s.Close())
	assert.Error(t, s.Ping(context.Background()))
}

// testTenant is the tenant newStore provisions — through the real JIT path,
// the only way tenants are born. The name has no significance.
const testTenant = "default"

func newStore(t *testing.T) *Store {
	t.Helper()
	s, err := Open(t.TempDir()+"/eitri.db", "10.77.0.0/16")
	require.NoError(t, err)
	tn, err := s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
	require.NoError(t, err)
	require.Equal(t, testTenant, tn.ID)
	t.Cleanup(func() { s.Close() })
	return s
}

func enrollHost(t *testing.T, s *Store) Host {
	t.Helper()
	tok, err := s.CreateEnrollmentToken(testTenant)
	require.NoError(t, err)
	h, err := s.RedeemEnrollmentToken(tok, EnrollFacts{Name: "host-a", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""})
	require.NoError(t, err)
	return h
}

func TestVMByTenantName(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeVM(t, s, h, "web-1")

	got, err := s.VMByTenantName(testTenant, "web-1")
	require.NoError(t, err)
	assert.Equal(t, vm.ID, got.ID)
	assert.Equal(t, h.ID, got.HostID)

	// Unknown name ⇒ ErrNoRows (the resolver reads this as ok=false).
	_, err = s.VMByTenantName(testTenant, "nope")
	assert.ErrorIs(t, err, sql.ErrNoRows)

	// Tombstoned VMs are not resolvable — the gate must not tunnel to a dead VM.
	require.NoError(t, s.TombstoneVM(vm.ID))
	_, err = s.VMByTenantName(testTenant, "web-1")
	assert.ErrorIs(t, err, sql.ErrNoRows)
}

func TestGetVM(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeVM(t, s, h, "web-1")

	got, err := s.GetVM(vm.ID)
	require.NoError(t, err)
	assert.Equal(t, vm.ID, got.ID)
	assert.Equal(t, vm.Name, got.Name)
	assert.Equal(t, h.ID, got.HostID)

	// Unknown id ⇒ ErrNoRows (callers read this as not-found).
	_, err = s.GetVM("no-such-id")
	assert.ErrorIs(t, err, sql.ErrNoRows)

	// Unlike VMByTenantName, GetVM must still find a tombstoned row — the
	// patch/delete/restore callers operate on VMs that may be tombstoned.
	require.NoError(t, s.TombstoneVM(vm.ID))
	got, err = s.GetVM(vm.ID)
	require.NoError(t, err)
	assert.Equal(t, vm.ID, got.ID)
	assert.NotNil(t, got.DeletedAt)
}

// TestVMNetworkRoundTrip pins that the empty string is the NAT default an
// unset create stores, and a named network survives the row whole.
func TestVMNetworkRoundTrip(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)

	require.NoError(t, s.CreateVM(VM{
		ID: "vm-lan", HostID: h.ID, Name: "on-lan",
		ImageURL: "http://img", ImageSHA256: "abc",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
		Network: "lan",
	}))
	got, err := s.GetVM("vm-lan")
	require.NoError(t, err)
	assert.Equal(t, "lan", got.Network)

	// A VM created without naming a network gets the NAT underlay, spelled ''.
	require.NoError(t, s.CreateVM(VM{
		ID: "vm-nat", HostID: h.ID, Name: "on-nat",
		ImageURL: "http://img", ImageSHA256: "abc",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
	}))
	got, err = s.GetVM("vm-nat")
	require.NoError(t, err)
	assert.Equal(t, "", got.Network)
}

// TestRecordVMNetworkIPKeepsBothAddresses pins that the two addresses a
// networked guest has are two independent columns: the named network's lease
// lands in network_ip and never touches the private-fabric address the gate
// and every exposure aim at.
func TestRecordVMNetworkIPKeepsBothAddresses(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	require.NoError(t, s.CreateVM(VM{
		ID: "vm-lan", HostID: h.ID, Name: "on-lan", ImageURL: "u", ImageSHA256: "s",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", Network: "lan",
	}))

	// A fresh row has the reservation and nothing from the site's DHCP yet.
	_, err := s.RecordVMStatus("vm-lan", h.ID, "ready", "", "10.77.1.2")
	require.NoError(t, err)
	got, err := s.GetVM("vm-lan")
	require.NoError(t, err)
	assert.Equal(t, "10.77.1.2", got.AssignedIP)
	assert.Empty(t, got.NetworkIP, "nothing has answered on the named NIC yet")

	require.NoError(t, s.RecordVMNetworkIP("vm-lan", h.ID, "192.168.0.42"))
	got, err = s.GetVM("vm-lan")
	require.NoError(t, err)
	assert.Equal(t, "192.168.0.42", got.NetworkIP)
	assert.Equal(t, "10.77.1.2", got.AssignedIP, "the private address is a separate fact")

	// And the status write does not disturb the discovered one either.
	_, err = s.RecordVMStatus("vm-lan", h.ID, "failed", "boom", "10.77.1.2")
	require.NoError(t, err)
	got, err = s.GetVM("vm-lan")
	require.NoError(t, err)
	assert.Equal(t, "192.168.0.42", got.NetworkIP)
}

// TestRecordVMNetworkIPKeepsPriorOnNothingUsable pins the empty-never-clears
// rule assigned_ip already follows: a report with no address, or one naming
// nobody, leaves the last known address standing. A guest whose lease renews
// to a different address is still followed.
func TestRecordVMNetworkIPKeepsPriorOnNothingUsable(t *testing.T) {
	const prior = "192.168.0.42"
	for _, tc := range []struct {
		name string
		ip   string
		want string
	}{
		{"a new lease on the site's network", "192.168.0.77", "192.168.0.77"},
		{"none discovered", "", prior},
		{"link-local: the site's DHCP never answered", "169.254.11.2", prior},
		{"loopback", "127.0.0.1", prior},
		{"unspecified", "0.0.0.0", prior},
		{"multicast", "224.0.0.1", prior},
		{"unparseable", "not-an-ip", prior},
	} {
		t.Run(tc.name, func(t *testing.T) {
			s := newStore(t)
			h := enrollHost(t, s)
			require.NoError(t, s.CreateVM(VM{
				ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u", ImageSHA256: "s",
				VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", Network: "lan",
			}))
			require.NoError(t, s.RecordVMNetworkIP("vm1", h.ID, prior))

			require.NoError(t, s.RecordVMNetworkIP("vm1", h.ID, tc.ip))
			vm, err := s.GetVM("vm1")
			require.NoError(t, err)
			assert.Equal(t, tc.want, vm.NetworkIP)
		})
	}
}

// TestRecordVMNetworkIPRefusesNetworklessVM pins that a VM which never named
// a network cannot grow a network_ip: the pairing is impossible, so the write
// itself refuses it rather than leaving it for a client to hide. The same
// sql.ErrNoRows a foreign host or a missing row gets is what a report against
// the NAT underlay gets too.
func TestRecordVMNetworkIPRefusesNetworklessVM(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	require.NoError(t, s.CreateVM(VM{
		ID: "vm-nat", HostID: h.ID, Name: "on-nat", ImageURL: "u", ImageSHA256: "s",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
	}))

	err := s.RecordVMNetworkIP("vm-nat", h.ID, "192.168.0.42")
	require.ErrorIs(t, err, sql.ErrNoRows)
	got, err := s.GetVM("vm-nat")
	require.NoError(t, err)
	assert.Empty(t, got.NetworkIP, "a VM with no named network stores no discovered address")
}

// TestRecordVMNetworkIPUnknownVM pins that a report for a row that is gone is
// an error, not a silent no-op — the same answer RecordVMStatus gives.
func TestRecordVMNetworkIPUnknownVM(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	assert.ErrorIs(t, s.RecordVMNetworkIP("nope", h.ID, "192.168.0.42"), sql.ErrNoRows)
}

// TestRecordVMNetworkIPRefusesAnotherHostsVM pins the same idiom
// RecordVMHostKey uses: host_id is a predicate on the UPDATE, not a check made
// before it, so a lease reported for a VM that belongs to a different host
// changes no rows and gets sql.ErrNoRows rather than silently landing.
func TestRecordVMNetworkIPRefusesAnotherHostsVM(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	require.NoError(t, s.CreateVM(VM{
		ID: "vm1", HostID: h.ID, Name: "on-lan", ImageURL: "u", ImageSHA256: "s",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", Network: "lan",
	}))

	err := s.RecordVMNetworkIP("vm1", "some-other-host", "192.168.0.42")
	require.ErrorIs(t, err, sql.ErrNoRows)
	got, err := s.GetVM("vm1")
	require.NoError(t, err)
	assert.Empty(t, got.NetworkIP, "a claim from a host that does not hold this VM writes nothing")
}

// TestRecordVMStatusRefusesAnotherHostsVM pins the same idiom RecordVMNetworkIP
// and RecordVMHostKey use: host_id is a predicate on the UPDATE, not a check
// made before it, so a lifecycle line reported for a VM that belongs to a
// different host changes no rows and gets sql.ErrNoRows. The three fields it
// guards are the ones the owning tenant reads for whether its guest is alive
// and where to reach it.
func TestRecordVMStatusRefusesAnotherHostsVM(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	require.NoError(t, s.CreateVM(VM{
		ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u", ImageSHA256: "s",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
	}))
	_, err := s.RecordVMStatus("vm1", h.ID, "ready", "", "10.77.1.2")
	require.NoError(t, err)

	_, err = s.RecordVMStatus("vm1", "some-other-host", "failed", "seized", "10.9.9.9")
	require.ErrorIs(t, err, sql.ErrNoRows)

	got, err := s.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, "ready", got.Status, "a claim from a host that does not hold this VM writes nothing")
	assert.Empty(t, got.LastError)
	assert.Equal(t, "10.77.1.2", got.AssignedIP)
}

// TestHardDeleteVMRefusesAnotherHostsVM pins the predicate on the reap. A
// tombstoned row is a restore window, and an ack from a host that does not hold
// the VM would close that window before its own host had torn the guest down.
// ForceDeleteVM is the deliberate exception, and it is the server's own.
func TestHardDeleteVMRefusesAnotherHostsVM(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeVM(t, s, h, "leaving-1")
	require.NoError(t, s.TombstoneVM(vm.ID))

	err := s.HardDeleteVM(vm.ID, "some-other-host")
	require.ErrorIs(t, err, sql.ErrNoRows)
	_, err = s.GetVM(vm.ID)
	require.NoError(t, err, "the refused ack must leave the row inside its grace window")

	require.NoError(t, s.RestoreVM(vm.ID), "the window a foreign ack must not close is still open")

	require.NoError(t, s.TombstoneVM(vm.ID))
	require.NoError(t, s.ForceDeleteVM(vm.ID),
		"the server's own backstop names no host: it runs when the host that would ack is gone")
	_, err = s.GetVM(vm.ID)
	assert.ErrorIs(t, err, sql.ErrNoRows)
}

func TestRecordVMHostKeyCertifiesOnlyItsOwnHostsVM(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)

	require.NoError(t, s.CreateVM(VM{
		ID: "vm1", HostID: h.ID, Name: "with-hostcert", ImageURL: "u", ImageSHA256: "abc",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
	}))
	// A create writes no key material of any kind: the host owns the key.
	created, err := s.VMByTenantName(testTenant, "with-hostcert")
	require.NoError(t, err)
	assert.Empty(t, created.SSHHostPubKey)
	assert.Empty(t, created.SSHHostCert)

	const pubkey = "ssh-ed25519 AAAAfakepub guest"
	const cert = "ssh-ed25519-cert-v01@openssh.com AAAAfakecert host\n"
	require.NoError(t, s.RecordVMHostKey("vm1", h.ID, pubkey, cert))

	// Both halves must round-trip through the read paths the snapshot and the
	// gate rely on: SpecForHost (agent-facing) and VMByTenantName.
	_, vms, err := s.SpecForHost(h.ID)
	require.NoError(t, err)
	require.Len(t, vms, 1)
	assert.Equal(t, pubkey, vms[0].SSHHostPubKey)
	assert.Equal(t, cert, vms[0].SSHHostCert)

	byName, err := s.VMByTenantName(testTenant, "with-hostcert")
	require.NoError(t, err)
	assert.Equal(t, pubkey, byName.SSHHostPubKey)
	assert.Equal(t, cert, byName.SSHHostCert)

	// A host may only speak for the VMs it holds. The host_id is a predicate on
	// the UPDATE, so a claim from anyone else changes nothing at all.
	err = s.RecordVMHostKey("vm1", "some-other-host", "ssh-ed25519 AAAAevil x", "evil-cert")
	require.ErrorIs(t, err, sql.ErrNoRows)
	unchanged, err := s.VMByTenantName(testTenant, "with-hostcert")
	require.NoError(t, err)
	assert.Equal(t, pubkey, unchanged.SSHHostPubKey)
	assert.Equal(t, cert, unchanged.SSHHostCert)
}

func TestRecordVMHostKeyRefusesATombstonedVM(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	require.NoError(t, s.CreateVM(VM{
		ID: "vm1", HostID: h.ID, Name: "doomed", ImageURL: "u", ImageSHA256: "abc",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
	}))
	require.NoError(t, s.TombstoneVM("vm1"))
	assert.ErrorIs(t, s.RecordVMHostKey("vm1", h.ID, "ssh-ed25519 AAAApub g", "cert"), sql.ErrNoRows)
}

// TestOpenDropsTheEscrowedHostKeyColumn is the upgrade from a database written
// when the control plane generated guests' host keys: the column and every key
// in it go, and nothing else about the VM does. Dropping the column is what
// destroys the keys — there is no sweep to have run, and no way to put one back.
func TestOpenDropsTheEscrowedHostKeyColumn(t *testing.T) {
	path := t.TempDir() + "/eitri.db"
	s, err := Open(path, "10.77.0.0/16")
	require.NoError(t, err)
	tn, err := s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
	require.NoError(t, err)
	require.Equal(t, testTenant, tn.ID)
	h := enrollHost(t, s)
	require.NoError(t, s.CreateVM(VM{
		ID: "vm1", HostID: h.ID, Name: "legacy", ImageURL: "u", ImageSHA256: "abc",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
	}))

	// Put the database back into the shape an earlier release left it: the
	// column present, a private key in it, and a certificate beside it.
	_, err = s.db.Exec(`ALTER TABLE vms ADD COLUMN ssh_host_key TEXT NOT NULL DEFAULT ''`)
	require.NoError(t, err)
	_, err = s.db.Exec(`UPDATE vms SET ssh_host_key='PRIVATE', ssh_host_cert='cert-line' WHERE id='vm1'`)
	require.NoError(t, err)
	// An exposure, because vms is referenced ON DELETE CASCADE: a migration that
	// rebuilt the table by dropping it would take this row with it.
	_, err = s.CreateExposure("vm1", 22, 30001, "tcp")
	require.NoError(t, err)
	require.NoError(t, s.Close())

	up, err := Open(path, "10.77.0.0/16")
	require.NoError(t, err)
	t.Cleanup(func() { up.Close() })

	var cols int
	require.NoError(t, up.db.QueryRow(
		`SELECT count(*) FROM pragma_table_info('vms') WHERE name='ssh_host_key'`).Scan(&cols))
	assert.Equal(t, 0, cols, "the column, and every key in it, must be gone")

	// Only that column goes. The certificate is public and still in use, the
	// VM row is intact, and so is everything hanging off it.
	vm, err := up.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, "legacy", vm.Name)
	assert.Equal(t, "cert-line", vm.SSHHostCert)
	exps, err := up.ListExposuresForVM("vm1")
	require.NoError(t, err)
	assert.Len(t, exps, 1, "dropping a column must not cascade into exposures")

	// Idempotent: a database that has already been through this opens clean.
	require.NoError(t, up.Close())
	again, err := Open(path, "10.77.0.0/16")
	require.NoError(t, err)
	t.Cleanup(func() { again.Close() })
	vm, err = again.GetVM("vm1")
	require.NoError(t, err)
	assert.Equal(t, "cert-line", vm.SSHHostCert)
}

// TestTheRetiredPersistentColumnStaysForRollback pins the compatibility this
// release keeps rather than the drop it would rather do. Nothing here reads or
// writes vms.persistent, but v0.0.5's Open runs `UPDATE vms SET persistent = 1`
// before it will serve anything: a database this binary has opened must still
// have the column, or rolling back a bad release leaves the plane down in both
// directions. Both databases must satisfy that — the one upgraded from v0.0.5
// and the one this release created from nothing — so the test asserts the same
// thing twice, once about each, by running v0.0.5's statement verbatim.
func TestTheRetiredPersistentColumnStaysForRollback(t *testing.T) {
	// (a) upgraded: a v0.0.5-shaped database, column and all.
	upgraded := t.TempDir() + "/eitri.db"
	s, err := Open(upgraded, "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)
	for _, name := range []string{"live", "deleted"} {
		require.NoError(t, s.CreateVM(VM{
			ID: name, HostID: h.ID, Name: name, ImageURL: "u", ImageSHA256: "abc",
			VCPUs: 2, MemMB: 512, DiskGB: 5, PowerState: "running",
		}))
	}
	require.NoError(t, s.TombstoneVM("deleted"))
	// A value only the older release could have written. Nothing here sets the
	// column, so if it survives the reopen the column was left alone rather than
	// dropped and put back — and the row v0.0.5 has to fix is still there to fix.
	_, err = s.db.Exec(`UPDATE vms SET persistent = 0 WHERE id='live'`)
	require.NoError(t, err)
	require.NoError(t, s.Close())

	up, err := Open(upgraded, "10.77.0.0/16")
	require.NoError(t, err)
	t.Cleanup(func() { up.Close() })

	var kept int
	require.NoError(t, up.db.QueryRow(`SELECT persistent FROM vms WHERE id='live'`).Scan(&kept))
	assert.Equal(t, 0, kept, "the column and its rows must survive an upgrade untouched")

	// v0.0.5's first write, run against the database v0.0.6 handed back.
	_, err = up.db.Exec(`UPDATE vms SET persistent = 1 WHERE persistent = 0`)
	require.NoError(t, err, "v0.0.5 must be able to start on a database v0.0.6 has opened")
	require.NoError(t, up.db.QueryRow(`SELECT persistent FROM vms WHERE id='live'`).Scan(&kept))
	assert.Equal(t, 1, kept, "v0.0.5's backfill must reach the row it targets")

	for _, id := range []string{"live", "deleted"} {
		vm, err := up.GetVM(id)
		require.NoError(t, err)
		assert.Equal(t, id, vm.Name, "%s must have survived the migration", id)
		assert.Equal(t, int64(2), vm.VCPUs, "%s must have kept its fields", id)
	}
	deleted, err := up.GetVM("deleted")
	require.NoError(t, err)
	assert.NotNil(t, deleted.DeletedAt, "a tombstoned VM stays tombstoned")

	// (b) fresh: a database this release created. The column is not in the
	// CREATE TABLE, so only the migration can have put it there — and a plane
	// installed at v0.0.6 has to be as rollable as one upgraded to it.
	fresh := newStore(t)
	hf := enrollHost(t, fresh)
	require.NoError(t, fresh.CreateVM(VM{
		ID: "new", HostID: hf.ID, Name: "new", ImageURL: "u", ImageSHA256: "abc",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
	}))
	_, err = fresh.db.Exec(`UPDATE vms SET persistent = 1 WHERE persistent = 0`)
	require.NoError(t, err, "a database created by this release must roll back too")

	// A row this release wrote left the column alone, so v0.0.5 reads back the
	// value it would have written itself: every VM is persistent.
	var persistent int
	require.NoError(t, fresh.db.QueryRow(`SELECT persistent FROM vms WHERE id='new'`).Scan(&persistent))
	assert.Equal(t, 1, persistent, "a VM created here must read as persistent to v0.0.5")
}

func TestOpenReplacesTheProtocolBlindHostPortIndex(t *testing.T) {
	path := 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)
	require.NoError(t, s.CreateVM(VM{
		ID: "vm1", HostID: h.ID, Name: "legacy", ImageURL: "u", ImageSHA256: "abc",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running",
	}))

	// Put the database back into the shape an earlier release left it: one host
	// port, one exposure, whatever the protocol.
	_, err = s.db.Exec(`DROP INDEX exposures_host_port_proto`)
	require.NoError(t, err)
	_, err = s.db.Exec(`CREATE UNIQUE INDEX exposures_host_port ON exposures(host_id, host_port)`)
	require.NoError(t, err)
	_, err = s.CreateExposure("vm1", 22, 30001, "tcp")
	require.NoError(t, err)
	require.NoError(t, s.Close())

	up, err := Open(path, "10.77.0.0/16")
	require.NoError(t, err)
	t.Cleanup(func() { up.Close() })

	var n int
	require.NoError(t, up.db.QueryRow(
		`SELECT count(*) FROM sqlite_master WHERE type='index' AND name='exposures_host_port'`).Scan(&n))
	assert.Equal(t, 0, n, "the index that spanned only (host, port) must be gone")

	_, err = up.CreateExposure("vm1", 53, 30001, "udp")
	assert.NoError(t, err, "the same host port in the other protocol is a second grant")
	_, err = up.CreateExposure("vm1", 54, 30001, "udp")
	assert.ErrorIs(t, err, ErrHostPortTaken, "the new index still holds the pair unique")
}

func TestSSHCertRevocation(t *testing.T) {
	s := newStore(t)

	// Unknown serial is not revoked.
	revoked, err := s.IsSSHCertRevoked(testTenant, 42)
	require.NoError(t, err)
	assert.False(t, revoked)

	// Revoke, then it reads back as revoked.
	require.NoError(t, s.RevokeSSHCert(testTenant, 42, "leaked laptop"))
	revoked, err = s.IsSSHCertRevoked(testTenant, 42)
	require.NoError(t, err)
	assert.True(t, revoked)

	// A different serial is unaffected.
	revoked, err = s.IsSSHCertRevoked(testTenant, 43)
	require.NoError(t, err)
	assert.False(t, revoked)

	// Idempotent: re-revoking keeps the original reason and does not error.
	require.NoError(t, s.RevokeSSHCert(testTenant, 42, "different reason"))
	list, err := s.ListRevokedSSHCerts(testTenant)
	require.NoError(t, err)
	require.Len(t, list, 1)
	assert.Equal(t, uint64(42), list[0].Serial)
	assert.Equal(t, "leaked laptop", list[0].Reason)
	assert.False(t, list[0].RevokedAt.IsZero())
}

// TestSSHCertRevocationTenantScoped pins that the revocation LIST is per-tenant:
// each tenant sees only the serials it filed, while gate enforcement
// (IsSSHCertRevoked) stays fleet-wide by serial. A re-revoke of a serial another
// tenant already owns keeps the original owner (ON CONFLICT DO NOTHING).
func TestSSHCertRevocationTenantScoped(t *testing.T) {
	s := newStore(t)
	beta, err := s.CreateTenantForIdentity("https://issuer.example", "sub-beta", "beta@example.com")
	require.NoError(t, err)

	require.NoError(t, s.RevokeSSHCert(testTenant, 100, "default's cert"))
	require.NoError(t, s.RevokeSSHCert(beta.ID, 200, "beta's cert"))

	def, err := s.ListRevokedSSHCerts(testTenant)
	require.NoError(t, err)
	require.Len(t, def, 1)
	assert.Equal(t, uint64(100), def[0].Serial)

	bl, err := s.ListRevokedSSHCerts(beta.ID)
	require.NoError(t, err)
	require.Len(t, bl, 1)
	assert.Equal(t, uint64(200), bl[0].Serial)

	// Gate enforcement is scoped to the revoking tenant: each serial is revoked
	// for its own tenant and live for the other. A fleet-wide answer here would
	// let either tenant deny a serial it has no claim to.
	for _, c := range []struct {
		tenant string
		serial uint64
		want   bool
	}{
		{testTenant, 100, true},
		{testTenant, 200, false},
		{beta.ID, 200, true},
		{beta.ID, 100, false},
	} {
		revoked, err := s.IsSSHCertRevoked(c.tenant, c.serial)
		require.NoError(t, err)
		assert.Equal(t, c.want, revoked, "tenant %s serial %d", c.tenant, c.serial)
	}

	// Two tenants may revoke the SAME serial independently — the rows are keyed
	// (tenant, serial). Keyed on serial alone the second would be dropped and
	// that tenant's certificate would keep working: a revocation that fails open.
	require.NoError(t, s.RevokeSSHCert(beta.ID, 100, "beta's own cert, same serial"))
	revoked, err := s.IsSSHCertRevoked(beta.ID, 100)
	require.NoError(t, err)
	assert.True(t, revoked, "beta's revocation must bind beta")

	bl, err = s.ListRevokedSSHCerts(beta.ID)
	require.NoError(t, err)
	assert.Len(t, bl, 2, "beta now owns rows for both serials it revoked")
}

// TestSSHCertRevocationLargeSerial guards the uint64→int64 bit-cast: a serial
// above math.MaxInt64 (as real crypto-random serials routinely are) must
// round-trip through insert, lookup, and list without truncation or collision.
func TestSSHCertRevocationLargeSerial(t *testing.T) {
	s := newStore(t)

	const big = uint64(0xFFFFFFFFFFFFFFFF) // all-ones: well past MaxInt64
	const other = uint64(0x8000000000000000)

	require.NoError(t, s.RevokeSSHCert(testTenant, big, "big"))
	revoked, err := s.IsSSHCertRevoked(testTenant, big)
	require.NoError(t, err)
	assert.True(t, revoked)

	// A distinct large serial must not collide with the first.
	revoked, err = s.IsSSHCertRevoked(testTenant, other)
	require.NoError(t, err)
	assert.False(t, revoked)

	require.NoError(t, s.RevokeSSHCert(testTenant, other, "other"))
	list, err := s.ListRevokedSSHCerts(testTenant)
	require.NoError(t, err)
	require.Len(t, list, 2)
	assert.ElementsMatch(t, []uint64{big, other}, []uint64{list[0].Serial, list[1].Serial},
		"ListRevokedSSHCerts must round-trip the uint64 serial exactly: serials ride SQLite as an int64 bit-cast, "+
			"so a lost sign bit lists a different cert than the one revoked and an operator auditing revocations matches the wrong key")
}

func TestTenantUserCAs(t *testing.T) {
	s := newStore(t)
	const ca1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIONE"
	const ca2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAATWO"

	has, err := s.TenantHasUserCA(testTenant)
	require.NoError(t, err)
	assert.False(t, has, "fresh tenant has no CA")

	require.NoError(t, s.AddTenantUserCA(testTenant, ca1, "tenant", "laptop", "admin"))
	require.NoError(t, s.AddTenantUserCA(testTenant, ca2, "tenant", "ci", "admin"))
	require.NoError(t, s.AddTenantUserCA(testTenant, ca1, "tenant", "dup", "admin")) // idempotent

	has, err = s.TenantHasUserCA(testTenant)
	require.NoError(t, err)
	assert.True(t, has)

	list, err := s.ListTenantUserCAs(testTenant)
	require.NoError(t, err)
	require.Len(t, list, 2, "duplicate insert is a no-op")

	ten, ok, err := s.TenantForUserCA(ca1)
	require.NoError(t, err)
	assert.True(t, ok)
	assert.Equal(t, testTenant, ten)

	_, ok, err = s.TenantForUserCA("ssh-ed25519 AAAAUNKNOWN")
	require.NoError(t, err)
	assert.False(t, ok)

	require.NoError(t, s.RemoveTenantUserCA(testTenant, ca1))
	_, ok, err = s.TenantForUserCA(ca1)
	require.NoError(t, err)
	assert.False(t, ok, "removed CA no longer resolves")
}

// TestTenantHasUserCAIsTenantScoped asserts the gate from the side that has no
// CA. TenantHasUserCA is the precondition for VM create, so a query that reads
// the table fleet-wide lets one tenant's registration unlock every other
// tenant's guests -- and every existing test asks only the tenant that just
// registered one, which any widened WHERE clause still answers correctly.
func TestTenantHasUserCAIsTenantScoped(t *testing.T) {
	s := newStore(t)
	const ca = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIONE"

	beta, err := s.CreateTenantForIdentity("https://issuer.test", "sub-beta", "beta@test.local")
	require.NoError(t, err)
	require.NoError(t, s.AddTenantUserCA(testTenant, ca, "tenant", "laptop", "admin"))

	has, err := s.TenantHasUserCA(beta.ID)
	require.NoError(t, err)
	assert.False(t, has,
		"a CA registered by tenant "+testTenant+" must not satisfy tenant "+beta.ID+"'s VM-create gate: "+
			"TenantHasUserCA read across tenants, so beta can boot guests trusting a CA it never registered")

	list, err := s.ListTenantUserCAs(beta.ID)
	require.NoError(t, err)
	assert.Empty(t, list, "tenant "+beta.ID+" registered no CA and must be shown none")

	has, err = s.TenantHasUserCA(testTenant)
	require.NoError(t, err)
	assert.True(t, has, "the registering tenant must still pass its own gate")
}

func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) {
	s := newStore(t)
	tok1, _ := s.CreateEnrollmentToken(testTenant)
	h1, err := s.RedeemEnrollmentToken(tok1, EnrollFacts{Name: "a", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""})
	require.NoError(t, err)
	assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR)

	_, err = s.RedeemEnrollmentToken(tok1, EnrollFacts{Name: "b", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""})
	assert.Error(t, err, "token must be one-time use")

	tok2, _ := s.CreateEnrollmentToken(testTenant)
	h2, _ := s.RedeemEnrollmentToken(tok2, EnrollFacts{Name: "b", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""})
	assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR)
}

func TestSpecMutationsBumpEpochButStatusWritesDoNot(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	e0, _ := s.Epoch()

	vm := VM{ID: "vm1", HostID: h.ID, Name: "sandbox-1", ImageURL: "http://x/img.qcow2",
		ImageSHA256: "abc", VCPUs: 2, MemMB: 2048, DiskGB: 10, PowerState: "running"}
	require.NoError(t, s.CreateVM(vm))
	e1, _ := s.Epoch()
	assert.Equal(t, e0+1, e1, "create bumps")

	require.NoError(t, s.SetVMPower("vm1", "stopped"))
	e2, _ := s.Epoch()
	assert.Equal(t, e1+1, e2, "power edit bumps")

	_, err := s.RecordVMStatus("vm1", h.ID, "ready", "", "10.77.1.2")
	require.NoError(t, err)
	e3, _ := s.Epoch()
	assert.Equal(t, e2, e3, "agent-reported status does NOT bump")

	require.NoError(t, s.TombstoneVM("vm1"))
	e4, _ := s.Epoch()
	assert.Equal(t, e3+1, e4, "tombstone bumps")

	require.NoError(t, s.HardDeleteVM("vm1", h.ID))
	e5, _ := s.Epoch()
	assert.Equal(t, e4+1, e5, "hard-delete bumps")
}

func TestNameUniqueAmongLiveRowsOnly(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	mk := func(id string) VM {
		return VM{ID: id, HostID: h.ID, Name: "sandbox-7", ImageURL: "u", ImageSHA256: "s",
			VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}
	}
	require.NoError(t, s.CreateVM(mk("vm1")))
	assert.Error(t, s.CreateVM(mk("vm2")), "live duplicate rejected")
	require.NoError(t, s.TombstoneVM("vm1"))
	assert.NoError(t, s.CreateVM(mk("vm3")), "tombstoned row must not block the name")
}

func TestSpecForHostIncludesTombstonedAndEpochConsistently(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u",
		ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
	require.NoError(t, s.TombstoneVM("vm1"))
	epoch, vms, err := s.SpecForHost(h.ID)
	require.NoError(t, err)
	e, _ := s.Epoch()
	assert.Equal(t, e, epoch)
	require.Len(t, vms, 1)
	assert.NotNil(t, vms[0].DeletedAt, "tombstoned rows stay in the snapshot until acked")
}

// TestRecordVMStatusKeepsReachableAddressesAndDropsUnusableOnes pins what the
// address guard asks. It does not ask where a host's guests live: a Mac's guests
// sit on whatever subnet vmnet runs, which no fleet allocation will ever
// contain. It asks only whether the value names a guest anything could reach.
// Whatever the answer, the (status, last_error) transition still writes.
func TestRecordVMStatusKeepsReachableAddressesAndDropsUnusableOnes(t *testing.T) {
	const prior = "10.77.1.9"
	for _, tc := range []struct {
		name string
		ip   string
		want string // the address the row should hold afterwards
	}{
		{"a guest on the host's own bridge", "10.77.1.20", "10.77.1.20"},
		{"a guest on a subnet the fleet never allocated", "192.168.64.7", "192.168.64.7"},
		{"unspecified", "0.0.0.0", prior},
		{"loopback", "127.0.0.1", prior},
		{"link-local: DHCP never answered", "169.254.11.2", prior},
		{"multicast", "224.0.0.1", prior},
		{"unparseable", "not-an-ip", prior},
		{"none reported", "", prior},
	} {
		t.Run(tc.name, func(t *testing.T) {
			s := newStore(t)
			h := enrollHost(t, s) // 10.77.1.0/24
			require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u",
				ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
			_, err := s.RecordVMStatus("vm1", h.ID, "ready", "", prior)
			require.NoError(t, err)

			written, err := s.RecordVMStatus("vm1", h.ID, "failed", "boom", tc.ip)
			require.NoError(t, err, "the address must never fail the status write")

			vm, err := s.GetVM("vm1")
			require.NoError(t, err)
			assert.Equal(t, "failed", vm.Status, "the status transition persists regardless")
			assert.Equal(t, "boom", vm.LastError)
			assert.Equal(t, tc.want, vm.AssignedIP)

			// What it says it wrote is what it wrote: the address on a keep, and
			// nothing at all on a drop — the caller cannot tell them apart otherwise.
			if tc.want == prior {
				assert.Empty(t, written, "a dropped address must be reported as written-nothing")
			} else {
				assert.Equal(t, tc.ip, written)
			}
		})
	}
}

func TestEnrollmentCIDRWorksForNonSlash16Pools(t *testing.T) {
	s, err := Open(t.TempDir()+"/eitri.db", "192.168.4.0/22")
	require.NoError(t, err)
	defer s.Close()
	_, err = s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
	require.NoError(t, err)
	tok, _ := s.CreateEnrollmentToken(testTenant)
	h, err := s.RedeemEnrollmentToken(tok, EnrollFacts{Name: "a", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""})
	require.NoError(t, err)
	assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved")
}

func TestEnrollmentFailsWhenPoolExhausted(t *testing.T) {
	s, err := Open(t.TempDir()+"/eitri.db", "10.9.8.0/23") // room for exactly one assignable /24 (idx 1)
	require.NoError(t, err)
	defer s.Close()
	_, err = s.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
	require.NoError(t, err)
	tok1, _ := s.CreateEnrollmentToken(testTenant)
	h, err := s.RedeemEnrollmentToken(tok1, EnrollFacts{Name: "a", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""})
	require.NoError(t, err)
	assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR)
	tok2, _ := s.CreateEnrollmentToken(testTenant)
	_, err = s.RedeemEnrollmentToken(tok2, EnrollFacts{Name: "b", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""})
	assert.ErrorContains(t, err, "exhausted")
}

func TestRecordVMStatusUnknownVMErrors(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	_, err := s.RecordVMStatus("nope", h.ID, "ready", "", "")
	assert.Error(t, err)
}

func TestForceRemoveHostPurgesVMs(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	makeVM(t, s, h, "vm-a")
	vmB := makeVM(t, s, h, "vm-b")
	// Tombstone one of the two so the purge is exercised against both live and
	// tombstoned rows (ForceRemoveHost deletes by host_id with no deleted_at
	// filter, so a tombstoned row is purged — and counted — like a live one).
	require.NoError(t, s.TombstoneVM(vmB.ID))

	gone, err := s.ForceRemoveHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, 2, gone.VMsPurged, "both VM rows — live and tombstoned — must be purged")
	assert.Zero(t, gone.VolumesDestroyed, "these VMs held no volumes")
	assert.Zero(t, gone.ClaimsUnbound)
	assert.Empty(t, gone.VolumeIDs)

	_, err = s.GetHost(h.ID)
	assert.Error(t, err, "host row must be gone")

	// Independently verify against the DB, not just the return value: no VM
	// row (including tombstoned ones) may remain for the purged host.
	vms, err := s.ListVMs()
	require.NoError(t, err)
	for _, vm := range vms {
		assert.NotEqual(t, h.ID, vm.HostID, "no VM rows should remain for the force-removed host")
	}

	// Its subnet is not returned to the pool: the column holds what the HOST
	// claimed, and re-issuing that to the next host would advise it to build a
	// network somebody else chose.
	tok, err := s.CreateEnrollmentToken(testTenant)
	require.NoError(t, err)
	h2, err := s.RedeemEnrollmentToken(tok, EnrollFacts{Name: "host-b", OS: "linux", Arch: "amd64", Provisioner: "cloudhv"})
	require.NoError(t, err)
	assert.NotEqual(t, h.BridgeCIDR, h2.BridgeCIDR, "a force-removed host's subnet is never re-issued")
}

func TestForceRemoveHostUnknownIsNoRows(t *testing.T) {
	s := newStore(t)
	_, err := s.ForceRemoveHost("nope")
	assert.ErrorIs(t, err, sql.ErrNoRows)
}

func TestCreateVMRejectsNonEnrolledHost(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	require.NoError(t, s.DecommissionHost(h.ID))
	err := s.CreateVM(VM{ID: "late", HostID: h.ID, Name: "late", ImageURL: "u",
		ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})
	assert.ErrorIs(t, err, ErrHostNotEnrolled)
}

func TestServerCertLoadOrCreatePersists(t *testing.T) {
	dir := t.TempDir()
	st, err := Open(filepath.Join(dir, "x.db"), "10.77.0.0/16")
	require.NoError(t, err)

	cert1, fp1, err := st.ServerCert()
	require.NoError(t, err)
	require.NotEmpty(t, cert1)
	require.Len(t, fp1, 64)

	cert2, fp2, err := st.ServerCert()
	require.NoError(t, err)
	assert.Equal(t, fp1, fp2) // same persisted cert, not regenerated
	assert.Equal(t, cert1, cert2)

	key, err := st.ServerKeyPEM()
	require.NoError(t, err)
	require.NotEmpty(t, key)
}

// TestSnapshotReadsAllThreeConsistently pins the Snapshot contract: one call
// returns hosts, per-host allocation, and VMs equal to the individual reads —
// but sourced from a single read transaction so the trio can never mix state
// from two different epochs (the SSE stream builds its payload from this).
func TestSnapshotReadsAllThreeConsistently(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "one",
		ImageURL: "http://x/i", ImageSHA256: "abc", VCPUs: 2, MemMB: 1024, DiskGB: 10, PowerState: "running"}))
	require.NoError(t, s.CreateVM(VM{ID: "vm2", HostID: h.ID, Name: "two",
		ImageURL: "http://x/i", ImageSHA256: "abc", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "stopped"}))

	hosts, alloc, vms, err := s.Snapshot()
	require.NoError(t, err)

	wantHosts, err := s.ListHosts()
	require.NoError(t, err)
	wantVMs, err := s.ListVMs()
	require.NoError(t, err)
	// Derive the expected allocation from the independently-read VM list
	// (rather than a second Snapshot() call, which would just check Snapshot
	// against itself) — the same pattern TestSnapshotIsAtomicUnderConcurrentWrites
	// uses to pin that alloc is exactly the sum of live VM resources per host.
	wantAlloc := map[string]Alloc{}
	for _, vm := range wantVMs {
		if vm.DeletedAt == nil {
			a := wantAlloc[vm.HostID]
			a.VCPUs += vm.VCPUs
			a.MemMB += vm.MemMB
			a.DiskGB += vm.DiskGB
			wantAlloc[vm.HostID] = a
		}
	}

	assert.Equal(t, wantHosts, hosts)
	assert.Equal(t, wantAlloc, alloc)
	assert.Equal(t, wantVMs, vms)
	assert.Equal(t, Alloc{VCPUs: 3, MemMB: 1536, DiskGB: 15}, alloc[h.ID])
}

// TestSnapshotIsAtomicUnderConcurrentWrites pins the single-tx property
// itself: alloc is derivable from vms, so any snapshot whose alloc disagrees
// with its own vms mixed two epochs. A mutator hammers create/tombstone while
// the main goroutine snapshots; the old three-call implementation released
// the sole connection between reads and fails this with high probability.
func TestSnapshotIsAtomicUnderConcurrentWrites(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)

	done := make(chan struct{})
	go func() {
		defer close(done)
		for range 300 {
			id := random.Hex(8)
			_ = s.CreateVM(VM{ID: id, HostID: h.ID, Name: "vm-" + id,
				ImageURL: "http://x/i", ImageSHA256: "abc",
				VCPUs: 1, MemMB: 256, DiskGB: 1, PowerState: "running"})
			_ = s.TombstoneVM(id)
		}
	}()

	for i := range 100 {
		_, alloc, vms, err := s.Snapshot()
		require.NoError(t, err)
		derived := map[string]Alloc{}
		for _, vm := range vms {
			if vm.DeletedAt == nil {
				a := derived[vm.HostID]
				a.VCPUs += vm.VCPUs
				a.MemMB += vm.MemMB
				a.DiskGB += vm.DiskGB
				derived[vm.HostID] = a
			}
		}
		require.Equal(t, derived, alloc,
			"snapshot %d: alloc disagrees with its own vms — reads mixed two epochs", i)
	}
	<-done
}

// TestAuditLogRoundTrip pins the audit trail contract: AppendAudit writes a
// timestamped action+detail row; ListAudit returns newest-first with a limit.
func TestAuditLogRoundTrip(t *testing.T) {
	s := newStore(t)
	require.NoError(t, s.AppendAudit(testTenant, "enroll-token.mint", `{"token_hash_prefix":"abcd1234"}`))
	require.NoError(t, s.AppendAudit(testTenant, "host.enroll", `{"host_id":"h1","name":"host-a"}`))

	rows, err := s.ListAudit(testTenant, 10)
	require.NoError(t, err)
	require.Len(t, rows, 2)
	assert.Equal(t, "host.enroll", rows[0].Action, "newest first")
	assert.Contains(t, rows[0].Detail, "h1")
	assert.False(t, rows[0].At.IsZero())

	one, err := s.ListAudit(testTenant, 1)
	require.NoError(t, err)
	require.Len(t, one, 1)
	assert.Equal(t, "host.enroll", one[0].Action)
}

// TestListVMEvents pins the per-VM timeline filter: only audit rows whose
// detail JSON carries the queried vm_id are returned, newest-first, respecting
// the limit — the endpoint's filter (json_extract on '$.vm_id') keys off it.
func TestListVMEvents(t *testing.T) {
	s := newStore(t)
	require.NoError(t, s.AppendAudit(testTenant, "vm.create", `{"vm_id":"vm-a","name":"alpha"}`))
	require.NoError(t, s.AppendAudit(testTenant, "vm.create", `{"vm_id":"vm-b","name":"bravo"}`))
	require.NoError(t, s.AppendAudit(testTenant, "vm.power", `{"vm_id":"vm-a","power":"stopped"}`))
	require.NoError(t, s.AppendAudit(testTenant, "vm.delete", `{"vm_id":"vm-a","name":"alpha"}`))

	rows, err := s.ListVMEvents(testTenant, "vm-a", 100)
	require.NoError(t, err)
	require.Len(t, rows, 3, "only vm-a rows, not vm-b's")
	assert.Equal(t, "vm.delete", rows[0].Action, "newest first")
	for _, e := range rows {
		assert.Contains(t, e.Detail, "vm-a")
		assert.NotContains(t, e.Detail, "vm-b")
	}

	limited, err := s.ListVMEvents(testTenant, "vm-a", 1)
	require.NoError(t, err)
	require.Len(t, limited, 1, "limit respected")
	assert.Equal(t, "vm.delete", limited[0].Action)
}

// failAuditWrites installs a trigger that aborts every audit_log insert, and
// returns the function that removes it. Injecting the failure at the SQLite
// level is the only seam available: *sql.DB is concrete, so the fault has to
// come from the database, not from Go.
func failAuditWrites(t *testing.T, s *Store) (drop func()) {
	t.Helper()
	_, err := s.db.Exec(`CREATE TRIGGER fail_audit BEFORE INSERT ON audit_log
		BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END`)
	require.NoError(t, err)
	return func() {
		_, err := s.db.Exec(`DROP TRIGGER fail_audit`)
		require.NoError(t, err)
	}
}

// TestRedeemRefusesToEnrollUnaudited proves the host.enroll audit row is
// written INSIDE the redeem transaction. With the audit write failing, the
// whole redeem must roll back: no host row, and the token still unused. An
// audit write moved after tx.Commit() would leave an enrolled host with no
// record of who enrolled it — and this test is the only evidence that a host
// cannot join the fleet unrecorded.
func TestRedeemRefusesToEnrollUnaudited(t *testing.T) {
	s := newStore(t)
	tok, err := s.CreateEnrollmentToken(testTenant)
	require.NoError(t, err)

	drop := failAuditWrites(t, s)
	_, err = s.RedeemEnrollmentToken(tok, EnrollFacts{Name: "host-a", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: "192.0.2.9"})
	require.Error(t, err, "a redeem whose audit row cannot be written must fail, not enroll silently")

	hosts, err := s.ListHosts()
	require.NoError(t, err)
	assert.Empty(t, hosts, "no host may exist without its audit row: the enroll and the audit write share one transaction, so a failed audit rolls the host row back")

	// The rollback must be complete, not partial: a token marked used by the
	// failed attempt would burn a one-shot enrollment credential for nothing.
	drop()
	h, err := s.RedeemEnrollmentToken(tok, EnrollFacts{Name: "host-a", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: "192.0.2.9"})
	require.NoError(t, err, "the rolled-back redeem must not have consumed the token")
	assert.NotEmpty(t, h.ID)
}

// TestRedeemWritesAuditRow pins what the host.enroll row carries: the host it
// created and the IP that presented the token, and never the raw token itself.
func TestRedeemWritesAuditRow(t *testing.T) {
	s := newStore(t)
	tok, err := s.CreateEnrollmentToken(testTenant)
	require.NoError(t, err)
	h, err := s.RedeemEnrollmentToken(tok, EnrollFacts{Name: "host-a", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: "192.0.2.9"})
	require.NoError(t, err)

	rows, err := s.ListAudit(testTenant, 5)
	require.NoError(t, err)
	require.NotEmpty(t, rows)
	assert.Equal(t, "host.enroll", rows[0].Action)
	assert.Contains(t, rows[0].Detail, h.ID)
	assert.Contains(t, rows[0].Detail, "192.0.2.9")
	assert.NotContains(t, rows[0].Detail, tok, "raw token must never reach the audit log")
}

// TestCredGenerationLifecycle pins per-host credential revocation: hosts
// enroll at generation 1; BumpCredGeneration invalidates outstanding
// credentials by incrementing the row; GetHost/ListHosts expose it.
func TestCredGenerationLifecycle(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	assert.Equal(t, int64(1), h.CredGeneration, "fresh enrollment starts at generation 1")

	got, err := s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, int64(1), got.CredGeneration)

	gen, err := s.BumpCredGeneration(h.ID, "192.0.2.7")
	require.NoError(t, err)
	assert.Equal(t, int64(2), gen)

	got, err = s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, int64(2), got.CredGeneration)

	_, err = s.BumpCredGeneration("no-such-host", "")
	assert.Error(t, err, "unknown host must error")
}

// TestBumpCredGenerationRefusesUnaudited proves the host.credential.revoke row
// is written INSIDE the bump transaction. With the audit write failing, the
// generation must stay put: a revoke that lands without its audit row is a
// credential invalidated by nobody, with no record of who did it or when.
func TestBumpCredGenerationRefusesUnaudited(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)

	drop := failAuditWrites(t, s)
	_, err := s.BumpCredGeneration(h.ID, "192.0.2.7")
	require.Error(t, err, "a revoke whose audit row cannot be written must fail, not revoke silently")

	got, err := s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, int64(1), got.CredGeneration, "no credential may be revoked without its audit row: the bump and the audit write share one transaction, so a failed audit rolls the bump back")

	drop()
	gen, err := s.BumpCredGeneration(h.ID, "192.0.2.7")
	require.NoError(t, err)
	assert.Equal(t, int64(2), gen, "the rolled-back bump must not have consumed a generation")
}

// TestBumpCredGenerationAudits pins what the host.credential.revoke row
// carries: the host whose credentials died and the IP that ordered it.
func TestBumpCredGenerationAudits(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	_, err := s.BumpCredGeneration(h.ID, "192.0.2.7")
	require.NoError(t, err)
	rows, err := s.ListAudit(testTenant, 1)
	require.NoError(t, err)
	require.Len(t, rows, 1)
	assert.Equal(t, "host.credential.revoke", rows[0].Action)
	assert.Contains(t, rows[0].Detail, h.ID)
	assert.Contains(t, rows[0].Detail, "192.0.2.7")
}

// TestPruneAuditRemovesOnlyOldRows pins retention: rows older than the
// window are deleted, newer rows survive, and the count is reported.
func TestPruneAuditRemovesOnlyOldRows(t *testing.T) {
	s := newStore(t)
	// Insert directly so the timestamps are controlled.
	old := time.Now().UTC().Add(-100 * 24 * time.Hour).Format(time.RFC3339)
	_, err := s.db.Exec(`INSERT INTO audit_log(at, tenant, action, detail) VALUES (?, ?, 'old.event', '{}')`, old, testTenant)
	require.NoError(t, err)
	require.NoError(t, s.AppendAudit(testTenant, "new.event", "{}"))

	n, err := s.PruneAudit(90 * 24 * time.Hour)
	require.NoError(t, err)
	assert.Equal(t, int64(1), n, "exactly the old row pruned")

	rows, err := s.ListAudit(testTenant, 10)
	require.NoError(t, err)
	require.Len(t, rows, 1)
	assert.Equal(t, "new.event", rows[0].Action)
}

// TestPruneAuditGuardsNonPositiveWindow pins the store-level contract: a
// zero/negative retention must never delete anything.
func TestPruneAuditGuardsNonPositiveWindow(t *testing.T) {
	s := newStore(t)
	require.NoError(t, s.AppendAudit(testTenant, "keep.me", "{}"))
	for _, d := range []time.Duration{0, -time.Hour} {
		n, err := s.PruneAudit(d)
		require.NoError(t, err)
		assert.Zero(t, n)
	}
	rows, err := s.ListAudit(testTenant, 5)
	require.NoError(t, err)
	assert.Len(t, rows, 1, "nothing may be deleted by a non-positive window")
}

func TestOpenSeedsNoTenants(t *testing.T) {
	// A fresh database starts with ZERO tenants: the only creation path is JIT
	// provisioning on first sign-in. (Open the store directly — newStore
	// provisions one.)
	s, err := Open(t.TempDir()+"/eitri.db", "10.77.0.0/16")
	require.NoError(t, err)
	t.Cleanup(func() { s.Close() })
	var n int
	require.NoError(t, s.db.QueryRow(`SELECT count(*) FROM tenants`).Scan(&n))
	assert.Equal(t, 0, n)
	// enrollment_tokens carries the tenant the enrolling host will join.
	var tenantCol int
	require.NoError(t, s.db.QueryRow(
		`SELECT count(*) FROM pragma_table_info('enrollment_tokens') WHERE name='tenant'`).Scan(&tenantCol))
	assert.Equal(t, 1, tenantCol)
}

// insertTenantHost plants a tenant + host row directly, bypassing enrollment.
func insertTenantHost(t *testing.T, s *Store, tenant, hostID string) {
	t.Helper()
	_, err := s.db.Exec(`INSERT INTO tenants(id, name, created_at) VALUES (?,?,?) ON CONFLICT DO NOTHING`,
		tenant, tenant, "2026-01-01T00:00:00Z")
	require.NoError(t, err)
	_, err = s.db.Exec(`INSERT INTO hosts(id, name, os, arch, provisioner, bridge_cidr, enrolled_at, tenant)
		VALUES (?, ?, 'linux', 'amd64', 'cloudhv', ?, '2026-01-01T00:00:00Z', ?)`,
		hostID, "host-"+hostID, "10.77.0.0/24", tenant)
	require.NoError(t, err)
}

func TestCreateVMDerivesTenantFromHost(t *testing.T) {
	s := newStore(t)
	insertTenantHost(t, s, "t2", "h-t2")
	// Client-supplied Tenant must be IGNORED — derivation is authoritative.
	require.NoError(t, s.CreateVM(VM{ID: "v1", HostID: "h-t2", Name: "web", ImageURL: "u", ImageSHA256: "s",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", Tenant: "attacker-chosen"}))
	vm, err := s.GetVM("v1")
	require.NoError(t, err)
	assert.Equal(t, "t2", vm.Tenant)
}

func TestVMNameUniquePerTenant(t *testing.T) {
	s := newStore(t)
	insertTenantHost(t, s, "t2", "h-t2")
	insertTenantHost(t, s, "t3", "h-t3")
	mk := func(id, host, name string) error {
		return s.CreateVM(VM{ID: id, HostID: host, Name: name, ImageURL: "u", ImageSHA256: "s",
			VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})
	}
	require.NoError(t, mk("v1", "h-t2", "web"))
	require.NoError(t, mk("v2", "h-t3", "web"), "same name in a DIFFERENT tenant must be allowed")
	assert.ErrorIs(t, mk("v3", "h-t2", "web"), ErrNameTaken, "same name in the SAME tenant must collide")
}

func TestVMByTenantNameIsScoped(t *testing.T) {
	s := newStore(t)
	insertTenantHost(t, s, "t2", "h-t2")
	insertTenantHost(t, s, "t3", "h-t3")
	require.NoError(t, s.CreateVM(VM{ID: "v1", HostID: "h-t2", Name: "web", ImageURL: "u", ImageSHA256: "s",
		VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
	got, err := s.VMByTenantName("t2", "web")
	require.NoError(t, err)
	assert.Equal(t, "v1", got.ID)
	_, err = s.VMByTenantName("t3", "web")
	assert.ErrorIs(t, err, sql.ErrNoRows, "resolution must not cross tenants")
	_, err = s.VMByTenantName(testTenant, "web")
	assert.ErrorIs(t, err, sql.ErrNoRows)
}

func TestEnrollmentCarriesTenantToHost(t *testing.T) {
	s := newStore(t)
	_, err := s.db.Exec(`INSERT INTO tenants(id, name, created_at) VALUES ('t2','t2','2026-01-01T00:00:00Z')`)
	require.NoError(t, err)

	tok, err := s.CreateEnrollmentToken("t2")
	require.NoError(t, err)
	h, err := s.RedeemEnrollmentToken(tok, EnrollFacts{Name: "box", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: "203.0.113.9"})
	require.NoError(t, err)
	assert.Equal(t, "t2", h.Tenant, "host must inherit the token's tenant")
	got, err := s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, "t2", got.Tenant)
}

func TestCreateEnrollmentTokenRejectsUnknownTenant(t *testing.T) {
	s := newStore(t)
	_, err := s.CreateEnrollmentToken("no-such-tenant")
	require.ErrorContains(t, err, "unknown tenant", "minting for a nonexistent tenant must fail at mint, not at redeem")
}

func TestUpdateHostFactsRoundTrips(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)

	require.NoError(t, s.UpdateHostFacts(h.ID, HostFacts{
		OSID: "debian", OSPretty: "Debian GNU/Linux 12 (bookworm)", OSVersion: "12",
		Kernel: "6.1.0-18-amd64", CPUModel: "AMD EPYC 7302P", Virt: "kvm",
	}))

	got, err := s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, "debian", got.OSID)
	assert.Equal(t, "Debian GNU/Linux 12 (bookworm)", got.OSPretty)
	assert.Equal(t, "12", got.OSVersion)
	assert.Equal(t, "6.1.0-18-amd64", got.Kernel)
	assert.Equal(t, "AMD EPYC 7302P", got.CPUModel)
	assert.Equal(t, "kvm", got.Virt)

	// listHosts must scan the same columns.
	hosts, err := s.ListHosts()
	require.NoError(t, err)
	require.Len(t, hosts, 1)
	assert.Equal(t, "6.1.0-18-amd64", hosts[0].Kernel)
}

// TestUpdateHostFactsRefreshesProvisioner pins the fix for a host that lied
// about itself: the provisioner is recorded at enrollment and can change under
// a host, so it is refreshed from every Hello like the rest of its identity. A
// Mac enrolled before its backend existed reported "inert" — a backend since
// deleted from the tree — for as long as the row stood.
func TestUpdateHostFactsRefreshesProvisioner(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s) // enrolls as cloudhv

	require.NoError(t, s.UpdateHostFacts(h.ID, HostFacts{Provisioner: "vfkit"}))
	got, err := s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, "vfkit", got.Provisioner)
}

// TestUpdateHostFactsEmptyNeverErases pins that silence is not a statement. An
// agent too old to report a field, or a Hello carrying no facts at all, must
// leave what an earlier agent said intact — otherwise a mixed-version fleet
// blanks its own host rows on every reconnect. A host with genuinely nothing to
// say sends a value saying so: a Mac reports virt "none", not "".
func TestUpdateHostFactsEmptyNeverErases(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)

	full := HostFacts{
		OSID: "ubuntu", OSPretty: "Ubuntu 26.04 LTS", OSVersion: "26.04",
		Kernel: "7.0.0-28-generic", CPUModel: "Apple M1", Virt: "none",
		Provisioner: "vfkit",
	}
	require.NoError(t, s.UpdateHostFacts(h.ID, full))
	require.NoError(t, s.UpdateHostFacts(h.ID, HostFacts{}), "an empty report must not fail")

	got, err := s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, full.OSID, got.OSID)
	assert.Equal(t, full.OSPretty, got.OSPretty)
	assert.Equal(t, full.OSVersion, got.OSVersion)
	assert.Equal(t, full.Kernel, got.Kernel)
	assert.Equal(t, full.CPUModel, got.CPUModel)
	assert.Equal(t, full.Virt, got.Virt)
	assert.Equal(t, full.Provisioner, got.Provisioner)

	// A partial report updates only what it carries.
	require.NoError(t, s.UpdateHostFacts(h.ID, HostFacts{Kernel: "7.0.1-generic"}))
	got, err = s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, "7.0.1-generic", got.Kernel)
	assert.Equal(t, "vfkit", got.Provisioner, "an unmentioned field keeps its value")
}

// TestRecordHostNetworkSanityChecksWithoutJudgingTopology pins what the fleet
// asks of a subnet a host reports: that it is a network, never which network it
// ought to be. A host's guest subnet is that host's business — a Mac's is
// vmnet's, and no fleet allocation will ever contain it.
func TestRecordHostNetworkSanityChecksWithoutJudgingTopology(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s) // allocated 10.77.1.0/24 from the pool

	for _, tc := range []struct {
		name, cidr string
		wantErr    bool
	}{
		{"the fleet's own allocation", "10.77.1.0/24", false},
		{"a subnet no allocation contains", "192.168.64.0/24", false},
		{"unparseable", "not-a-network", true},
		{"a bare address", "192.168.64.1", true},
		{"IPv6", "fd00::/64", true},
		{"empty", "", true},
	} {
		t.Run(tc.name, func(t *testing.T) {
			err := s.RecordHostNetwork(h.ID, tc.cidr)
			if tc.wantErr {
				require.Error(t, err)
				return
			}
			require.NoError(t, err)
			got, err := s.GetHost(h.ID)
			require.NoError(t, err)
			assert.Equal(t, tc.cidr, got.BridgeCIDR)
		})
	}

	// A rejected value must leave the last good one standing.
	require.NoError(t, s.RecordHostNetwork(h.ID, "192.168.64.0/24"))
	require.Error(t, s.RecordHostNetwork(h.ID, "rubbish"))
	got, err := s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, "192.168.64.0/24", got.BridgeCIDR)
}

func TestRecordHostUplink(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)

	// A freshly enrolled host has said nothing about its uplink yet.
	got, err := s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, "", got.UplinkAddr)

	require.NoError(t, s.RecordHostUplink(h.ID, "192.168.0.190"))
	got, err = s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, "192.168.0.190", got.UplinkAddr)

	// The list read carries it too — the console renders it from there.
	hosts, err := s.ListHosts()
	require.NoError(t, err)
	require.Len(t, hosts, 1)
	assert.Equal(t, "192.168.0.190", hosts[0].UplinkAddr)

	// A host that moves is recorded where it moved to.
	require.NoError(t, s.RecordHostUplink(h.ID, "10.0.0.7"))
	got, err = s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, "10.0.0.7", got.UplinkAddr)
}

func TestRecordHostUplinkRefusesWhatIsNotAnAddress(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	assert.Error(t, s.RecordHostUplink(h.ID, "not-an-address"))
	assert.Error(t, s.RecordHostUplink(h.ID, "192.168.0.190/24"))
	assert.Error(t, s.RecordHostUplink(h.ID, "127.0.0.1"), "an address that reaches nobody is not an uplink")

	got, err := s.GetHost(h.ID)
	require.NoError(t, err)
	assert.Equal(t, "", got.UplinkAddr, "a value that is not an address never reaches the row")
}

// TestHardDeleteVMRefusesLiveRows pins the tombstone-only guard both delete
// statements carry. Every caller swallows the error — the server's own sweep
// skips to the next row and the sync ack path only warns — so no HTTP status
// ever surfaces this refusal and the store is the only altitude with a signal.
func TestHardDeleteVMRefusesLiveRows(t *testing.T) {
	s := newStore(t)
	h := enrollHost(t, s)
	vm := makeVM(t, s, h, "live-1")
	_, err := s.CreateExposure(vm.ID, 22, 10022, "tcp")
	require.NoError(t, err)

	err = s.HardDeleteVM(vm.ID, h.ID)
	assert.ErrorIs(t, err, sql.ErrNoRows,
		"HardDeleteVM must refuse a live row: deleting it here skips the tombstone/teardown grace entirely, so the agent never learns the guest should die and it runs on orphaned")
	assert.ErrorIs(t, s.ForceDeleteVM(vm.ID), sql.ErrNoRows,
		"the server's backstop is unscoped by host, never by the tombstone: it reaps what an absent agent cannot ack, not what is running")

	_, err = s.GetVM(vm.ID)
	assert.NoError(t, err, "the refused delete must leave the row standing")
	exps, err := s.ListExposuresForVM(vm.ID)
	require.NoError(t, err)
	assert.Len(t, exps, 1,
		"the refused delete must not fire ON DELETE CASCADE: the VM's exposures went with it, tearing down published ports for a guest that is still running")

	require.NoError(t, s.TombstoneVM(vm.ID))
	require.NoError(t, s.HardDeleteVM(vm.ID, h.ID), "a tombstoned row is deletable — that is the whole point of the guard")
	_, err = s.GetVM(vm.ID)
	assert.Error(t, err, "the row is gone")
	exps, err = s.ListExposuresForVM(vm.ID)
	require.NoError(t, err)
	assert.Empty(t, exps, "the cascade fires on the legal path")
}

// TestCidrPoolFirstOpenWins pins that the guest-subnet pool is fixed at the
// first Open and a later --cidr-pool is ignored. Hosts hold their allocation
// for life, so honoring a changed flag would re-plan subnets under a fleet that
// is already routing on the old ones.
func TestCidrPoolFirstOpenWins(t *testing.T) {
	path := t.TempDir() + "/eitri.db"

	first, err := Open(path, "10.77.0.0/16")
	require.NoError(t, err)
	tn, err := first.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
	require.NoError(t, err)
	require.Equal(t, testTenant, tn.ID)
	require.NoError(t, first.Close())

	// The operator restarts the server with a different pool. It is ignored —
	// but no longer in silence; TestChangedCidrPoolWarns owns the signal. What
	// this test pins is that the fleet's subnets do not move underneath it.
	second, err := Open(path, "10.99.0.0/16")
	require.NoError(t, err)
	t.Cleanup(func() { second.Close() })

	h := enrollHost(t, second)
	assert.True(t, strings.HasPrefix(h.BridgeCIDR, "10.77."),
		"the cidr_pool is fixed at first Open: this host got %s, allocated from the pool passed at restart, so a fleet already routing 10.77.x has a peer on a subnet nothing routes to", h.BridgeCIDR)
}

// TestChangedCidrPoolWarns pins the signal, not the behaviour: the stored pool
// still wins (TestCidrPoolFirstOpenWins says why). What this asserts is that
// the operator is told. Without the line, editing --cidr-pool produces a clean
// startup, no complaint, and a plane that quietly kept the old value — and the
// flag is discovered to have been inert only when a newly-enrolled host lands
// on a subnet nobody routes to, or never.
func TestChangedCidrPoolWarns(t *testing.T) {
	path := t.TempDir() + "/eitri.db"
	openLogging := func(t *testing.T, pool string) string {
		t.Helper()
		var buf bytes.Buffer
		prev := slog.Default()
		t.Cleanup(func() { slog.SetDefault(prev) })
		slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
		s, err := Open(path, pool)
		require.NoError(t, err, "a drifted pool must never stop the plane booting: refusing would brick a "+
			"restart for anyone whose unit file already carries one, and their fleet is routing fine")
		t.Cleanup(func() { s.Close() })
		return buf.String()
	}

	assert.Empty(t, openLogging(t, "10.77.0.0/16"), "the first Open is what seeded the pool and has nothing to report")
	assert.Empty(t, openLogging(t, "10.77.0.0/16"), "a restart passing the pool that is already stored is not a misconfiguration")

	out := openLogging(t, "10.99.0.0/16")
	assert.Contains(t, out, "--cidr-pool was ignored",
		"a restart with a changed pool must say the flag did nothing, or the operator goes on believing it took")
	assert.Contains(t, out, "10.77.0.0/16", "the warning must name the pool that actually governs")
	assert.Contains(t, out, "10.99.0.0/16", "and the one that was discarded")
}