Files
tajniak81andClaude Opus 5.5 a47e15cc1c
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
PocketBase as a standalone database server
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>
2026-10-26 16:31:08 +01:00

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)
}
})
}
}