a73x

internal/server/store/evolve_test.go

Ref:   Size: 4.1 KiB   History

package store

import (
	"database/sql"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// ensureColumn must add a missing column exactly once and be a no-op after.
func TestEnsureColumnIdempotent(t *testing.T) {
	s := newStore(t)
	if err := ensureColumn(s.db, "tenants", "email", "TEXT NOT NULL DEFAULT ''"); err != nil {
		t.Fatalf("first ensureColumn: %v", err)
	}
	if err := ensureColumn(s.db, "tenants", "email", "TEXT NOT NULL DEFAULT ''"); err != nil {
		t.Fatalf("second ensureColumn: %v", err)
	}
	var n int
	if err := s.db.QueryRow(`SELECT count(*) FROM pragma_table_info('tenants') WHERE name='email'`).Scan(&n); err != nil {
		t.Fatal(err)
	}
	if n != 1 {
		t.Fatalf("email column count = %d, want 1", n)
	}
}

// TestRekeyRevokedSSHCertsCarriesRowsAndIsIdempotent covers the migration that
// re-keys revocation on (tenant, serial). A revocation the plane already holds
// must survive it — losing one silently un-revokes a certificate.
func TestRekeyRevokedSSHCertsCarriesRowsAndIsIdempotent(t *testing.T) {
	db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "old.db"))
	require.NoError(t, err)
	defer db.Close()

	// The table as it shipped: keyed on serial alone, tenant added later by
	// ensureColumn.
	_, err = db.Exec(`CREATE TABLE revoked_ssh_certs (
		serial     INTEGER PRIMARY KEY,
		revoked_at DATETIME NOT NULL,
		reason     TEXT NOT NULL DEFAULT ''
	)`)
	require.NoError(t, err)
	require.NoError(t, ensureColumn(db, "revoked_ssh_certs", "tenant", "TEXT NOT NULL DEFAULT 'default'"))
	_, err = db.Exec(`INSERT INTO revoked_ssh_certs(serial, tenant, revoked_at, reason)
		VALUES (7, 'alpha', '2026-01-01T00:00:00Z', 'leaked'), (9, 'beta', '2026-01-02T00:00:00Z', '')`)
	require.NoError(t, err)

	require.NoError(t, rekeyRevokedSSHCerts(db))

	assert.Equal(t, []string{"tenant", "serial"}, mustPK(t, db), "key is now composite, in key order")

	var n int
	require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs`).Scan(&n))
	assert.Equal(t, 2, n, "every revocation must survive the rebuild")
	var reason string
	require.NoError(t, db.QueryRow(`SELECT reason FROM revoked_ssh_certs WHERE tenant='alpha' AND serial=7`).Scan(&reason))
	assert.Equal(t, "leaked", reason, "columns carry across, not just rows")

	// Running again must be a no-op, since Open calls it on every boot.
	require.NoError(t, rekeyRevokedSSHCerts(db))
	require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM revoked_ssh_certs`).Scan(&n))
	assert.Equal(t, 2, n)

	// The point of the new key: two tenants may hold the same serial.
	_, err = db.Exec(`INSERT INTO revoked_ssh_certs(tenant, serial, revoked_at, reason)
		VALUES ('beta', 7, '2026-01-03T00:00:00Z', 'beta own cert')`)
	require.NoError(t, err, "the old key would have rejected this and failed open")
}

func mustPK(t *testing.T, db *sql.DB) []string {
	t.Helper()
	cols, err := pkColumns(db, "revoked_ssh_certs")
	require.NoError(t, err)
	return cols
}

// TestRekeyWarnsOnRetiredDefaultTenantRows pins that the migration notices
// revocations it is about to render inert. Scoping enforcement means rows filed
// under the retired 'default' tenant stop denying anything, and a certificate
// quietly working again is the one outcome nobody would spot.
func TestRekeyWarnsOnRetiredDefaultTenantRows(t *testing.T) {
	db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "legacy.db"))
	require.NoError(t, err)
	defer db.Close()
	_, err = db.Exec(`CREATE TABLE revoked_ssh_certs (
		serial INTEGER PRIMARY KEY, revoked_at DATETIME NOT NULL, reason TEXT NOT NULL DEFAULT '')`)
	require.NoError(t, err)
	require.NoError(t, ensureColumn(db, "revoked_ssh_certs", "tenant", "TEXT NOT NULL DEFAULT 'default'"))
	_, err = db.Exec(`INSERT INTO revoked_ssh_certs(serial, revoked_at) VALUES (5, '2026-01-01T00:00:00Z')`)
	require.NoError(t, err)

	require.NoError(t, rekeyRevokedSSHCerts(db))

	// The row is carried across rather than dropped — it is evidence, and an
	// operator may want to re-file it under the owning tenant.
	var tenant string
	require.NoError(t, db.QueryRow(`SELECT tenant FROM revoked_ssh_certs WHERE serial=5`).Scan(&tenant))
	assert.Equal(t, "default", tenant)
}