CI / Test (ubuntu-latest) (push) Waiting to run
CI / Test (windows-latest) (push) Waiting to run
CI / Storage tests on PostgreSQL (push) Waiting to run
CI / Complement (federation off) (push) Waiting to run
CI / golangci-lint (push) Waiting to run
CI / Smoke test the binary (push) Waiting to run
CI / Admin panel (push) Waiting to run
CI / Web client (push) Waiting to run
CI / Desktop client (ubuntu-24.04) (push) Waiting to run
CI / Desktop client (windows-latest) (push) Waiting to run
The pocketbase driver no longer embeds PocketBase: Warp keeps its data as PocketBase collections on a server of its own and reaches it only through the REST API, the way the postgres driver reaches PostgreSQL. database.connection_string is the server's URL, and Warp signs in as the superuser in WARP_PB_SUPERUSER_EMAIL / WARP_PB_SUPERUSER_PASSWORD (or a WARP_PB_SUPERUSER_FILE of the two). Every SQL table is a superuser-only collection of the same name and columns, created by warp migrate. Reads go through PocketBase's SQL endpoint with the SQL drivers' queries, via a read-only database/sql driver that inlines arguments, moves long ones into warp_params and pages past 1000 rows. Writes are record operations in batch requests under one write lock in the Warp process. Media is a protected file field in warp_media, local or in the S3 bucket Warp writes into PocketBase's settings. Search matches word prefixes with LIKE. The storage tests run against an in-process PocketBase and pass as on SQLite. Docker: the PocketBase stacks get a pocketbase service running tajniak81/pocketbase-docker-root (WARP_PB_IMAGE), with a pb-data volume and its admin UI on loopback; the AIO images run that PocketBase binary as a process beside the homeserver, making up a superuser when none is set. The .env examples, READMEs, docs and warp.yaml.example follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
6.5 KiB
Go
214 lines
6.5 KiB
Go
package storage_test
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage/pocketbase/pbtest"
|
|
)
|
|
|
|
// openTestDB opens each driver that can run without external services. The
|
|
// postgres driver joins in when WARP_TEST_POSTGRES_DSN is set, which is how
|
|
// CI runs the same tests against both engines; the pocketbase driver gets a
|
|
// real PocketBase server of its own, run in-process.
|
|
func openTestDB(t *testing.T, driver string) storage.Database {
|
|
t.Helper()
|
|
|
|
cfg := storage.Config{Driver: driver, MaxOpenConns: 4, MaxIdleConns: 2}
|
|
switch driver {
|
|
case storage.DriverSQLite:
|
|
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
|
|
cfg.ConnectionString = "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
|
|
case storage.DriverPostgres:
|
|
dsn := os.Getenv("WARP_TEST_POSTGRES_DSN")
|
|
if dsn == "" {
|
|
t.Skip("set WARP_TEST_POSTGRES_DSN to test the postgres driver")
|
|
}
|
|
cfg.ConnectionString = dsn
|
|
case storage.DriverPocketBase:
|
|
cfg.ConnectionString = pbtest.Start(t).URL
|
|
cfg.SuperuserEmail = pbtest.SuperuserEmail
|
|
cfg.SuperuserPassword = pbtest.SuperuserPassword
|
|
default:
|
|
t.Fatalf("no test setup for driver %q", driver)
|
|
}
|
|
|
|
db, err := storage.Open(t.Context(), cfg, nil)
|
|
if err != nil {
|
|
t.Fatalf("Open(%s): %v", driver, err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return db
|
|
}
|
|
|
|
// testDrivers are run by every storage test. PocketBase is here because
|
|
// parity with the SQL drivers is its whole contract.
|
|
var testDrivers = []string{storage.DriverSQLite, storage.DriverPostgres, storage.DriverPocketBase}
|
|
|
|
func TestDriversRegistered(t *testing.T) {
|
|
got := storage.Drivers()
|
|
for _, want := range []string{storage.DriverSQLite, storage.DriverPostgres, storage.DriverPocketBase} {
|
|
if !slices.Contains(got, want) {
|
|
t.Errorf("driver %q is not registered (have %v)", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenUnknownDriver(t *testing.T) {
|
|
_, err := storage.Open(t.Context(), storage.Config{Driver: "mysql"}, nil)
|
|
if err == nil {
|
|
t.Fatal("expected an error for an unknown driver")
|
|
}
|
|
}
|
|
|
|
func TestPocketBaseNeedsAServer(t *testing.T) {
|
|
if _, err := storage.Open(t.Context(), storage.Config{Driver: storage.DriverPocketBase}, nil); err == nil {
|
|
t.Fatal("expected an error for a pocketbase driver with no server URL")
|
|
}
|
|
_, err := storage.Open(t.Context(), storage.Config{
|
|
Driver: storage.DriverPocketBase,
|
|
ConnectionString: pbtest.Start(t).URL,
|
|
SuperuserEmail: pbtest.SuperuserEmail,
|
|
SuperuserPassword: "not-the-password",
|
|
}, nil)
|
|
if err == nil {
|
|
t.Fatal("expected an error for a superuser password the server refuses")
|
|
}
|
|
}
|
|
|
|
func TestPocketBaseKeepsMediaInItsStorage(t *testing.T) {
|
|
db := openTestDB(t, storage.DriverPocketBase)
|
|
if err := db.Migrate(t.Context()); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
provider, ok := db.(storage.BlobProvider)
|
|
if !ok {
|
|
t.Fatal("the pocketbase driver does not provide its own blob store")
|
|
}
|
|
blobs := provider.Blobs()
|
|
ctx := t.Context()
|
|
|
|
info, err := blobs.Put(ctx, strings.NewReader("hello media"), 1024)
|
|
if err != nil {
|
|
t.Fatalf("Put: %v", err)
|
|
}
|
|
if info.Size != 11 || len(info.SHA256) != 64 {
|
|
t.Fatalf("Put returned %+v", info)
|
|
}
|
|
if again, err := blobs.Put(ctx, strings.NewReader("hello media"), 1024); err != nil || again != info {
|
|
t.Errorf("the same content again = %+v, %v", again, err)
|
|
}
|
|
if _, err := blobs.Put(ctx, strings.NewReader("too long"), 3); !errors.Is(err, storage.ErrBlobTooLarge) {
|
|
t.Errorf("an oversize Put = %v, want ErrBlobTooLarge", err)
|
|
}
|
|
|
|
r, err := blobs.Open(ctx, info.SHA256)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
body, err := io.ReadAll(r)
|
|
_ = r.Close()
|
|
if err != nil || string(body) != "hello media" {
|
|
t.Errorf("read back %q, %v", body, err)
|
|
}
|
|
|
|
if err := blobs.Delete(ctx, info.SHA256); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
if err := blobs.Delete(ctx, info.SHA256); err != nil {
|
|
t.Errorf("deleting twice = %v", err)
|
|
}
|
|
if _, err := blobs.Open(ctx, info.SHA256); !errors.Is(err, storage.ErrNoSuchBlob) {
|
|
t.Errorf("Open after Delete = %v, want ErrNoSuchBlob", err)
|
|
}
|
|
if _, err := blobs.Open(ctx, "../../etc/passwd"); !errors.Is(err, storage.ErrNoSuchBlob) {
|
|
t.Errorf("Open of a non-hash = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateAndSettings(t *testing.T) {
|
|
for _, driver := range testDrivers {
|
|
t.Run(driver, func(t *testing.T) {
|
|
ctx := t.Context()
|
|
db := openTestDB(t, driver)
|
|
|
|
pending, err := db.PendingMigrations(ctx)
|
|
if err != nil {
|
|
t.Fatalf("PendingMigrations: %v", err)
|
|
}
|
|
if len(pending) == 0 {
|
|
t.Skip("database already migrated by a previous run")
|
|
}
|
|
|
|
if err := db.Migrate(ctx); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
// Migrating twice must be a no-op, not an error: it runs on
|
|
// every boot.
|
|
if err := db.Migrate(ctx); err != nil {
|
|
t.Fatalf("second Migrate: %v", err)
|
|
}
|
|
pending, err = db.PendingMigrations(ctx)
|
|
if err != nil {
|
|
t.Fatalf("PendingMigrations: %v", err)
|
|
}
|
|
if len(pending) != 0 {
|
|
t.Errorf("after Migrate, still pending: %v", pending)
|
|
}
|
|
|
|
settings := db.Settings()
|
|
if _, found, err := settings.Get(ctx, "federation.enabled"); err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
} else if found {
|
|
t.Fatal("expected no stored value on a fresh database")
|
|
}
|
|
|
|
if err := settings.Set(ctx, "federation.enabled", "true"); err != nil {
|
|
t.Fatalf("Set: %v", err)
|
|
}
|
|
if err := settings.Set(ctx, "federation.enabled", "false"); err != nil {
|
|
t.Fatalf("Set (overwrite): %v", err)
|
|
}
|
|
value, found, err := settings.Get(ctx, "federation.enabled")
|
|
if err != nil || !found {
|
|
t.Fatalf("Get after Set: value=%q found=%v err=%v", value, found, err)
|
|
}
|
|
if value != "false" {
|
|
t.Errorf("value = %q, want %q (the upsert must overwrite)", value, "false")
|
|
}
|
|
|
|
all, err := settings.GetAll(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetAll: %v", err)
|
|
}
|
|
if all["federation.enabled"] != "false" {
|
|
t.Errorf("GetAll = %v", all)
|
|
}
|
|
|
|
if err := settings.Delete(ctx, "federation.enabled"); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
if err := settings.Delete(ctx, "federation.enabled"); err != nil {
|
|
t.Errorf("deleting an absent key must not fail: %v", err)
|
|
}
|
|
if _, found, _ := settings.Get(ctx, "federation.enabled"); found {
|
|
t.Error("value survived Delete")
|
|
}
|
|
|
|
if err := db.Ping(ctx); err != nil {
|
|
t.Errorf("Ping: %v", err)
|
|
}
|
|
if db.Driver() != driver {
|
|
t.Errorf("Driver() = %q, want %q", db.Driver(), driver)
|
|
}
|
|
})
|
|
}
|
|
}
|