Files

396 lines
12 KiB
Go
Raw Permalink Normal View History

package db_test
import (
"bytes"
"context"
"database/sql"
"errors"
"testing"
)
// Plugin persistence (migration 015) and the per-plugin KV namespace had no
// coverage. The KV namespace is the isolation boundary between plugins — the
// primary key is (plugin_id, key), so a plugin cannot name another plugin's
// namespace — and that property is worth pinning explicitly.
func installTestPlugin(t *testing.T, database interface {
InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error)
}, name string) int64 {
t.Helper()
id, err := database.InstallPlugin(context.Background(), name, "1.0.0", `{"name":"`+name+`"}`)
if err != nil {
t.Fatalf("InstallPlugin(%s): %v", name, err)
}
return id
}
func TestInstallPlugin_AndGetPlugin(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
if id == 0 {
t.Fatal("InstallPlugin returned id 0")
}
got, err := database.GetPlugin(ctx, id)
if err != nil {
t.Fatalf("GetPlugin: %v", err)
}
if got.Name != "hello" {
t.Errorf("Name = %q, want %q", got.Name, "hello")
}
if got.Version != "1.0.0" {
t.Errorf("Version = %q, want %q", got.Version, "1.0.0")
}
if got.Enabled {
t.Error("Enabled = true; a freshly installed plugin must default to disabled")
}
if got.InstalledAt.IsZero() {
t.Error("InstalledAt is zero; scanPluginRow should have parsed the timestamp")
}
}
func TestInstallPlugin_ReinstallUpdatesInPlace(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
first := installTestPlugin(t, database, "hello")
// ON CONFLICT(name) DO UPDATE — a reinstall must upgrade the existing row
// rather than create a second one, and must return the same id (the path
// where LastInsertId is 0 and the code falls back to a lookup by name).
second, err := database.InstallPlugin(ctx, "hello", "2.0.0", `{"name":"hello","v":2}`)
if err != nil {
t.Fatalf("InstallPlugin reinstall: %v", err)
}
if second != first {
t.Errorf("reinstall returned id %d, want the original %d", second, first)
}
got, err := database.GetPlugin(ctx, first)
if err != nil {
t.Fatalf("GetPlugin: %v", err)
}
if got.Version != "2.0.0" {
t.Errorf("Version = %q after reinstall, want %q", got.Version, "2.0.0")
}
all, err := database.ListPlugins(ctx)
if err != nil {
t.Fatalf("ListPlugins: %v", err)
}
if len(all) != 1 {
t.Errorf("ListPlugins len = %d after reinstall, want 1", len(all))
}
}
func TestEnableDisablePlugin(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
if err := database.EnablePlugin(ctx, id); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
got, err := database.GetPlugin(ctx, id)
if err != nil {
t.Fatalf("GetPlugin: %v", err)
}
if !got.Enabled {
t.Error("Enabled = false after EnablePlugin")
}
if err := database.DisablePlugin(ctx, id); err != nil {
t.Fatalf("DisablePlugin: %v", err)
}
got, err = database.GetPlugin(ctx, id)
if err != nil {
t.Fatalf("GetPlugin: %v", err)
}
if got.Enabled {
t.Error("Enabled = true after DisablePlugin")
}
}
func TestGetPluginByName(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
got, err := database.GetPluginByName(ctx, "hello")
if err != nil {
t.Fatalf("GetPluginByName: %v", err)
}
if got.ID != id {
t.Errorf("ID = %d, want %d", got.ID, id)
}
_, err = database.GetPluginByName(ctx, "does-not-exist")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("GetPluginByName on a missing name = %v, want sql.ErrNoRows", err)
}
}
func TestGetPlugin_Missing(t *testing.T) {
database := newMigratedTestDB(t)
_, err := database.GetPlugin(context.Background(), 4242)
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("GetPlugin on a missing id = %v, want sql.ErrNoRows", err)
}
}
func TestUninstallPlugin_CascadesKV(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
if err := database.PluginKVSet(ctx, id, "k", []byte("v")); err != nil {
t.Fatalf("PluginKVSet: %v", err)
}
if err := database.UninstallPlugin(ctx, id); err != nil {
t.Fatalf("UninstallPlugin: %v", err)
}
if _, err := database.GetPlugin(ctx, id); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("GetPlugin after uninstall = %v, want sql.ErrNoRows", err)
}
// plugin_kv has ON DELETE CASCADE; an uninstall must not leave the removed
// plugin's stored data behind for whatever reinstalls under that id.
var leftover int
row := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM plugin_kv WHERE plugin_id = ?`, id)
if err := row.Scan(&leftover); err != nil {
t.Fatalf("count plugin_kv: %v", err)
}
if leftover != 0 {
t.Errorf("%d plugin_kv rows survived the uninstall, want 0", leftover)
}
}
func TestListPlugins_OrderedByName(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
empty, err := database.ListPlugins(ctx)
if err != nil {
t.Fatalf("ListPlugins on empty: %v", err)
}
if len(empty) != 0 {
t.Errorf("ListPlugins on empty = %v, want none", empty)
}
for _, name := range []string{"zeta", "alpha", "mid"} {
installTestPlugin(t, database, name)
}
got, err := database.ListPlugins(ctx)
if err != nil {
t.Fatalf("ListPlugins: %v", err)
}
want := []string{"alpha", "mid", "zeta"}
if len(got) != len(want) {
t.Fatalf("ListPlugins len = %d, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].Name != w {
t.Errorf("ListPlugins[%d].Name = %q, want %q", i, got[i].Name, w)
}
}
}
func TestPluginKV_SetGetDelete(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
if _, err := database.PluginKVGet(ctx, id, "absent"); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("PluginKVGet on a missing key = %v, want sql.ErrNoRows", err)
}
if err := database.PluginKVSet(ctx, id, "greeting", []byte("hi")); err != nil {
t.Fatalf("PluginKVSet: %v", err)
}
v, err := database.PluginKVGet(ctx, id, "greeting")
if err != nil {
t.Fatalf("PluginKVGet: %v", err)
}
if !bytes.Equal(v, []byte("hi")) {
t.Errorf("value = %q, want %q", v, "hi")
}
// ON CONFLICT(plugin_id, key) DO UPDATE — a second Set overwrites.
if err := database.PluginKVSet(ctx, id, "greeting", []byte("hello again")); err != nil {
t.Fatalf("PluginKVSet overwrite: %v", err)
}
v, err = database.PluginKVGet(ctx, id, "greeting")
if err != nil {
t.Fatalf("PluginKVGet after overwrite: %v", err)
}
if !bytes.Equal(v, []byte("hello again")) {
t.Errorf("value = %q after overwrite, want %q", v, "hello again")
}
if err := database.PluginKVDelete(ctx, id, "greeting"); err != nil {
t.Fatalf("PluginKVDelete: %v", err)
}
if _, err := database.PluginKVGet(ctx, id, "greeting"); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("PluginKVGet after delete = %v, want sql.ErrNoRows", err)
}
}
func TestPluginKV_NamespacesAreIsolated(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
a := installTestPlugin(t, database, "plugin-a")
b := installTestPlugin(t, database, "plugin-b")
if err := database.PluginKVSet(ctx, a, "secret", []byte("a-value")); err != nil {
t.Fatalf("PluginKVSet a: %v", err)
}
if err := database.PluginKVSet(ctx, b, "secret", []byte("b-value")); err != nil {
t.Fatalf("PluginKVSet b: %v", err)
}
// Same key, different namespaces — neither plugin can read or clobber the
// other's value. This is the whole isolation guarantee of the KV store.
got, err := database.PluginKVGet(ctx, a, "secret")
if err != nil {
t.Fatalf("PluginKVGet a: %v", err)
}
if !bytes.Equal(got, []byte("a-value")) {
t.Errorf("plugin-a read %q, want %q", got, "a-value")
}
if err := database.PluginKVDelete(ctx, a, "secret"); err != nil {
t.Fatalf("PluginKVDelete a: %v", err)
}
got, err = database.PluginKVGet(ctx, b, "secret")
if err != nil {
t.Fatalf("PluginKVGet b after deleting a's key: %v", err)
}
if !bytes.Equal(got, []byte("b-value")) {
t.Errorf("plugin-b value = %q after plugin-a deleted its own key, want %q", got, "b-value")
}
}
func TestPluginKVScan(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
other := installTestPlugin(t, database, "other")
for k, v := range map[string]string{
"cfg:a": "1",
"cfg:b": "2",
"state:x": "3",
} {
if err := database.PluginKVSet(ctx, id, k, []byte(v)); err != nil {
t.Fatalf("PluginKVSet(%s): %v", k, err)
}
}
if err := database.PluginKVSet(ctx, other, "cfg:a", []byte("nope")); err != nil {
t.Fatalf("PluginKVSet other: %v", err)
}
got, err := database.PluginKVScan(ctx, id, "cfg:", 100)
if err != nil {
t.Fatalf("PluginKVScan: %v", err)
}
if len(got) != 2 {
t.Fatalf("PluginKVScan returned %d entries, want 2: %v", len(got), got)
}
if !bytes.Equal(got["cfg:a"], []byte("1")) || !bytes.Equal(got["cfg:b"], []byte("2")) {
t.Errorf("PluginKVScan = %v, want cfg:a=1 and cfg:b=2 from this plugin only", got)
}
limited, err := database.PluginKVScan(ctx, id, "cfg:", 1)
if err != nil {
t.Fatalf("PluginKVScan with limit: %v", err)
}
if len(limited) != 1 {
t.Errorf("PluginKVScan with limit 1 returned %d entries, want 1", len(limited))
}
none, err := database.PluginKVScan(ctx, id, "nomatch:", 100)
if err != nil {
t.Fatalf("PluginKVScan no match: %v", err)
}
if len(none) != 0 {
t.Errorf("PluginKVScan with a non-matching prefix = %v, want empty", none)
}
}
// TestPluginKVScan_IsBinaryPrefixMatch pins that PluginKVScan is an exact,
// case-sensitive BINARY prefix match — matching the exact-match semantics of
// PluginKVGet/Set/Delete on the same table — rather than a SQL LIKE pattern
// match, where '_' and '%' are wildcards and matching is ASCII
// case-insensitive.
func TestPluginKVScan_IsBinaryPrefixMatch(t *testing.T) {
database := newMigratedTestDB(t)
ctx := context.Background()
id := installTestPlugin(t, database, "hello")
for k, v := range map[string]string{
"cfg_a": "underscore-match",
"cfgXa": "not-a-prefix-match",
"Key1": "capital-key",
"key1": "lowercase-key",
} {
if err := database.PluginKVSet(ctx, id, k, []byte(v)); err != nil {
t.Fatalf("PluginKVSet(%s): %v", k, err)
}
}
// '_' in the prefix must be a literal underscore, not a LIKE
// single-character wildcard, so "cfgXa" must not be returned.
underscoreScan, err := database.PluginKVScan(ctx, id, "cfg_", 100)
if err != nil {
t.Fatalf("PluginKVScan cfg_: %v", err)
}
if _, ok := underscoreScan["cfgXa"]; ok {
t.Errorf("PluginKVScan(%q) = %v; '_' matched any character like a LIKE wildcard, but PluginKVGet treats \"cfg_a\" and \"cfgXa\" as distinct keys", "cfg_", underscoreScan)
}
if len(underscoreScan) != 1 || !bytes.Equal(underscoreScan["cfg_a"], []byte("underscore-match")) {
t.Errorf("PluginKVScan(%q) = %v, want exactly {cfg_a: underscore-match}", "cfg_", underscoreScan)
}
// Matching must be case-sensitive (BINARY), matching key = ? on the same
// table, so scanning "Key" must not return "key1".
caseScan, err := database.PluginKVScan(ctx, id, "Key", 100)
if err != nil {
t.Fatalf("PluginKVScan Key: %v", err)
}
if _, ok := caseScan["key1"]; ok {
t.Errorf("PluginKVScan(%q) = %v; LIKE's ASCII case-insensitivity matched \"key1\", but PluginKVGet/Delete treat \"Key1\" and \"key1\" as distinct keys", "Key", caseScan)
}
if len(caseScan) != 1 || !bytes.Equal(caseScan["Key1"], []byte("capital-key")) {
t.Errorf("PluginKVScan(%q) = %v, want exactly {Key1: capital-key}", "Key", caseScan)
}
}
func TestInstallPlugin_ReinstallReturnsCorrectID_AfterOtherWrites(t *testing.T) {
database := openMigratedMemory(t)
ctx := context.Background()
id1, err := database.InstallPlugin(ctx, "rowid-plugin", "1.0", "{}")
if err != nil {
t.Fatalf("InstallPlugin: %v", err)
}
// Other INSERTs on the shared single writer connection move
// last_insert_rowid past the plugin's id; the upsert's UPDATE branch
// does not reset it.
seedUser(t, database, "rowid-mover-1")
seedUser(t, database, "rowid-mover-2")
seedUser(t, database, "rowid-mover-3")
id2, err := database.InstallPlugin(ctx, "rowid-plugin", "2.0", "{}")
if err != nil {
t.Fatalf("InstallPlugin reinstall: %v", err)
}
if id2 != id1 {
t.Errorf("reinstall returned id %d, want %d — EnablePlugin/plugin_kv would target a nonexistent row", id2, id1)
}
}