Files
openfsd/cmd/openfsd-migrate-to-sqlite/e2e_postgres_test.go
Reese Norris e0d7532423 feat: make openfsd SQLite-only with Postgres migration tool
Drop runtime PostgreSQL support (repos, dual migration trees, driver
selection). Reject non-sqlite DATABASE_DRIVER at startup with a pointer
to the cutover path. Add openfsd-migrate-to-sqlite (unit + embedded-
Postgres e2e), wiki docs for operators, and the sweatbox integrated
simulator design doc.
2026-07-17 16:09:55 -04:00

383 lines
12 KiB
Go

package main
import (
"bytes"
"database/sql"
_ "embed"
"fmt"
"io"
"net"
"path/filepath"
"strings"
"testing"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/renorris/openfsd/internal/db"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
//go:embed legacy_pg_schema.sql
var legacyPGSchema string
// TestE2E_PostgresToSQLite starts a real ephemeral PostgreSQL (no system
// install; binaries downloaded by embedded-postgres into a temp dir), seeds it
// with the historical openfsd schema + data, runs the migrator end-to-end, and
// proves the SQLite result is correct. Postgres is stopped via t.Cleanup; the
// runtime directory lives under t.TempDir so it is removed automatically.
func TestE2E_PostgresToSQLite(t *testing.T) {
if testing.Short() {
t.Skip("skipping embedded Postgres E2E in -short mode")
}
pgDSN := startEmbeddedPostgres(t)
pg, err := sql.Open("postgres", pgDSN)
require.NoError(t, err)
t.Cleanup(func() { _ = pg.Close() })
require.NoError(t, pg.Ping())
// Apply historical openfsd Postgres schema exactly as main-branch deploys had it.
_, err = pg.Exec(legacyPGSchema)
require.NoError(t, err, "apply legacy postgres schema")
// Realistic bcrypt hash; char(60) will right-pad in Postgres.
const plainPassword = "admin-pass-测试"
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.MinCost)
require.NoError(t, err)
require.LessOrEqual(t, len(hash), 60)
// Explicit CIDs with a gap; NULL names; admin rating.
_, err = pg.Exec(`
INSERT INTO public.users (cid, password, first_name, last_name, network_rating) VALUES
(1, $1, 'Default', 'Admin', 12),
(2, $1, NULL, NULL, 1),
(10, $1, 'Gap', 'User', 2)`, string(hash))
require.NoError(t, err)
_, err = pg.Exec(`SELECT setval('public.users_cid_seq', 10)`)
require.NoError(t, err)
_, err = pg.Exec(`
INSERT INTO config (key, value) VALUES
('JWT_SECRET_KEY', '0123456789abcdef0123456789abcdef'),
('WELCOME_MESSAGE', 'Connected to openfsd'),
('FSD_SERVER_HOSTNAME', 'fsd.example.test'),
('FSD_SERVER_IDENT', 'OPENFSD'),
('FSD_SERVER_LOCATION', 'Earth'),
('API_SERVER_BASE_URL', 'https://api.example.test')`)
require.NoError(t, err)
// char(60) pads short values; bcrypt is already 60 chars so insert a short
// sentinel on CID 2 to prove the migrator trims padding.
_, err = pg.Exec(`UPDATE public.users SET password = 'short' WHERE cid = 2`)
require.NoError(t, err)
var rawPW string
require.NoError(t, pg.QueryRow(`SELECT password FROM public.users WHERE cid = 2`).Scan(&rawPW))
require.Equal(t, 60, len(rawPW), "postgres char(60) stores fixed width")
require.Equal(t, "short", strings.TrimSpace(rawPW))
outPath := filepath.Join(t.TempDir(), "openfsd-migrated.db")
// Full CLI path: run() → migrate() → copyUsers/copyConfig
var stdout, stderr bytes.Buffer
code := run([]string{"-from", pgDSN, "-to", outPath}, &stderr, &stdout)
require.Equal(t, 0, code, "stderr=%s stdout=%s", stderr.String(), stdout.String())
require.Contains(t, stdout.String(), "copied 3 user(s) and 6 config row(s)")
require.Contains(t, stdout.String(), "OK: migrated PostgreSQL")
require.FileExists(t, outPath)
// Source Postgres must be unchanged (read-only migrate).
var pgUsers, pgConfig int
require.NoError(t, pg.QueryRow(`SELECT COUNT(*) FROM public.users`).Scan(&pgUsers))
require.NoError(t, pg.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&pgConfig))
require.Equal(t, 3, pgUsers)
require.Equal(t, 6, pgConfig)
// Verify SQLite contents.
sqliteDB, err := sql.Open("sqlite", outPath)
require.NoError(t, err)
t.Cleanup(func() { _ = sqliteDB.Close() })
var nUsers, nConfig int
require.NoError(t, sqliteDB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&nUsers))
require.NoError(t, sqliteDB.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&nConfig))
require.Equal(t, 3, nUsers)
require.Equal(t, 6, nConfig)
type userRow struct {
cid int
password string
first, last sql.NullString
networkRating int
}
rows, err := sqliteDB.Query(`SELECT cid, password, first_name, last_name, network_rating FROM users ORDER BY cid`)
require.NoError(t, err)
defer rows.Close()
var got []userRow
for rows.Next() {
var r userRow
require.NoError(t, rows.Scan(&r.cid, &r.password, &r.first, &r.last, &r.networkRating))
got = append(got, r)
}
require.NoError(t, rows.Err())
require.Len(t, got, 3)
require.Equal(t, 1, got[0].cid)
require.Equal(t, "Default", got[0].first.String)
require.Equal(t, "Admin", got[0].last.String)
require.Equal(t, 12, got[0].networkRating)
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(got[0].password), []byte(plainPassword)))
require.Equal(t, len(strings.TrimSpace(got[0].password)), len(got[0].password))
require.Equal(t, 2, got[1].cid)
require.False(t, got[1].first.Valid)
require.False(t, got[1].last.Valid)
require.Equal(t, 1, got[1].networkRating)
require.Equal(t, "short", got[1].password, "char(60) padding must be trimmed")
require.Equal(t, 10, got[2].cid)
require.Equal(t, "Gap", got[2].first.String)
// AUTOINCREMENT continues after highest migrated CID
var seq int
require.NoError(t, sqliteDB.QueryRow(`SELECT seq FROM sqlite_sequence WHERE name = 'users'`).Scan(&seq))
require.Equal(t, 10, seq)
res, err := sqliteDB.Exec(`INSERT INTO users (password, first_name, last_name, network_rating) VALUES ('x', 'n', 'ew', 1)`)
require.NoError(t, err)
id, err := res.LastInsertId()
require.NoError(t, err)
require.Equal(t, int64(11), id)
// Config key/values round-trip
wantConfig := map[string]string{
"JWT_SECRET_KEY": "0123456789abcdef0123456789abcdef",
"WELCOME_MESSAGE": "Connected to openfsd",
"FSD_SERVER_HOSTNAME": "fsd.example.test",
"FSD_SERVER_IDENT": "OPENFSD",
"FSD_SERVER_LOCATION": "Earth",
"API_SERVER_BASE_URL": "https://api.example.test",
}
for k, v := range wantConfig {
var gotVal string
require.NoError(t, sqliteDB.QueryRow(`SELECT value FROM config WHERE key = ?`, k).Scan(&gotVal), k)
require.Equal(t, v, gotVal, k)
}
// Production repositories accept the migrated file (login + config paths).
repos, err := db.NewRepositories(sqliteDB)
require.NoError(t, err)
u, err := repos.UserRepo.GetUserByCID(1)
require.NoError(t, err)
require.True(t, repos.UserRepo.VerifyPasswordHash(plainPassword, u.Password))
msg, err := repos.ConfigRepo.Get("WELCOME_MESSAGE")
require.NoError(t, err)
require.Equal(t, "Connected to openfsd", msg)
// Second migrate to same path must refuse overwrite.
var stdout2, stderr2 bytes.Buffer
code = run([]string{"-from", pgDSN, "-to", outPath}, &stderr2, &stdout2)
require.Equal(t, 1, code)
require.Contains(t, stderr2.String(), "already exists")
// migrate() error path: destination directory missing after Postgres opens.
err = migrate(pgDSN, filepath.Join(t.TempDir(), "no-such-dir", "x.db"), io.Discard)
require.Error(t, err)
// migrate() error path: copyUsers fails when users table is missing.
_, err = pg.Exec(`DROP TABLE public.users CASCADE`)
require.NoError(t, err)
err = migrate(pgDSN, filepath.Join(t.TempDir(), "nousers.db"), io.Discard)
require.Error(t, err)
require.Contains(t, err.Error(), "select users")
}
// TestE2E_MigrateCopyConfigFails exercises migrate() when users copy succeeds
// but config is missing on the Postgres source.
func TestE2E_MigrateCopyConfigFails(t *testing.T) {
if testing.Short() {
t.Skip("skipping embedded Postgres E2E in -short mode")
}
pgDSN := startEmbeddedPostgres(t)
pg, err := sql.Open("postgres", pgDSN)
require.NoError(t, err)
t.Cleanup(func() { _ = pg.Close() })
_, err = pg.Exec(`
CREATE TABLE public.users (
cid SERIAL PRIMARY KEY,
password CHAR(60) NOT NULL,
first_name VARCHAR(255),
last_name VARCHAR(255),
network_rating SMALLINT NOT NULL
);
INSERT INTO public.users (password, first_name, last_name, network_rating)
VALUES ('x', NULL, NULL, 1);
-- deliberately no config table
`)
require.NoError(t, err)
err = migrate(pgDSN, filepath.Join(t.TempDir(), "noconfig.db"), io.Discard)
require.Error(t, err)
require.Contains(t, err.Error(), "select config")
}
// TestE2E_PostgresEmptyTables migrates a schema-only Postgres DB (0 rows).
func TestE2E_PostgresEmptyTables(t *testing.T) {
if testing.Short() {
t.Skip("skipping embedded Postgres E2E in -short mode")
}
pgDSN := startEmbeddedPostgres(t)
pg, err := sql.Open("postgres", pgDSN)
require.NoError(t, err)
t.Cleanup(func() { _ = pg.Close() })
_, err = pg.Exec(legacyPGSchema)
require.NoError(t, err)
outPath := filepath.Join(t.TempDir(), "empty.db")
var stdout, stderr bytes.Buffer
code := run([]string{"-from", pgDSN, "-to", outPath}, &stderr, &stdout)
require.Equal(t, 0, code, stderr.String())
require.Contains(t, stdout.String(), "copied 0 user(s) and 0 config row(s)")
sqliteDB, err := sql.Open("sqlite", outPath)
require.NoError(t, err)
t.Cleanup(func() { _ = sqliteDB.Close() })
var n int
require.NoError(t, sqliteDB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n))
require.Zero(t, n)
}
// TestE2E_PostgresUsersFallbackSchema proves the unqualified "FROM users"
// fallback when public.users is absent but search_path exposes users.
func TestE2E_PostgresUsersFallbackSchema(t *testing.T) {
if testing.Short() {
t.Skip("skipping embedded Postgres E2E in -short mode")
}
pgDSN := startEmbeddedPostgres(t)
pg, err := sql.Open("postgres", pgDSN)
require.NoError(t, err)
t.Cleanup(func() { _ = pg.Close() })
_, err = pg.Exec(`
CREATE SCHEMA app;
CREATE TABLE app.users (
cid INTEGER PRIMARY KEY,
password TEXT NOT NULL,
first_name TEXT,
last_name TEXT,
network_rating SMALLINT NOT NULL
);
CREATE TABLE public.config (
key VARCHAR NOT NULL,
value VARCHAR NOT NULL
);
CREATE UNIQUE INDEX config_key_uindex ON public.config (key);
INSERT INTO app.users VALUES (5, 'hash', 'X', 'Y', 3);
INSERT INTO public.config VALUES ('K', 'V');
`)
require.NoError(t, err)
// Keyword DSN (not URL): append options so unqualified "users" resolves to
// app.users. public.users is missing → first SELECT fails → fallback.
dsn := pgDSN + " options='-csearch_path=app,public'"
outPath := filepath.Join(t.TempDir(), "fallback.db")
var stdout, stderr bytes.Buffer
code := run([]string{"-from", dsn, "-to", outPath}, &stderr, &stdout)
require.Equal(t, 0, code, "stderr=%s stdout=%s", stderr.String(), stdout.String())
require.Contains(t, stdout.String(), "copied 1 user(s) and 1 config row(s)")
sqliteDB, err := sql.Open("sqlite", outPath)
require.NoError(t, err)
t.Cleanup(func() { _ = sqliteDB.Close() })
var cid int
var pw string
require.NoError(t, sqliteDB.QueryRow(`SELECT cid, password FROM users`).Scan(&cid, &pw))
require.Equal(t, 5, cid)
require.Equal(t, "hash", pw)
}
// startEmbeddedPostgres boots a real PostgreSQL in a temp directory using
// github.com/fergusstrange/embedded-postgres (downloads platform binaries on
// first use; no system Postgres package required). Stop + wipe via t.Cleanup.
func startEmbeddedPostgres(t *testing.T) (dsn string) {
t.Helper()
port := freeTCPPort(t)
runtimePath := t.TempDir()
dataPath := filepath.Join(runtimePath, "data")
const (
user = "openfsd"
pass = "openfsd"
name = "openfsd"
)
cfg := embeddedpostgres.DefaultConfig().
Version(embeddedpostgres.V16).
Username(user).
Password(pass).
Database(name).
RuntimePath(runtimePath).
DataPath(dataPath).
Port(uint32(port)).
StartTimeout(90 * time.Second).
Logger(io.Discard)
database := embeddedpostgres.NewDatabase(cfg)
err := database.Start()
require.NoError(t, err, "start embedded postgres (downloads binaries on first run)")
stopped := false
stop := func() {
if stopped {
return
}
stopped = true
if err := database.Stop(); err != nil {
t.Logf("embedded postgres stop: %v", err)
}
}
t.Cleanup(stop)
dsn = fmt.Sprintf(
"host=127.0.0.1 port=%d user=%s password=%s dbname=%s sslmode=disable",
port, user, pass, name,
)
// Wait until accepting connections (Start usually does this, belt-and-suspenders).
deadline := time.Now().Add(30 * time.Second)
for {
db, err := sql.Open("postgres", dsn)
if err == nil {
pingErr := db.Ping()
_ = db.Close()
if pingErr == nil {
break
}
}
if time.Now().After(deadline) {
stop()
t.Fatalf("embedded postgres not ready: last err=%v", err)
}
time.Sleep(50 * time.Millisecond)
}
return dsn
}
func freeTCPPort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := ln.Addr().(*net.TCPAddr).Port
require.NoError(t, ln.Close())
return port
}