check / check (push) Waiting to run
The slowest test packages spent their time waiting rather than testing. db.Params gains an optional PasswordCost (zero means bcrypt.DefaultCost), and the db, ircserver and handlers tests hash at bcrypt.MinCost. A test IRC server's relay checks its queue on broker notification instead of polling. The service and handlers tests build config.Config directly and run in parallel; config exports DefaultTrustedProxies and ParseTrustedProxies for them. Fixed sleeps give way to waits for the event. Production defaults are unchanged. What still takes time is CPU work under -race or a timeout a test is about. Model: opus-5-5
363 lines
8.4 KiB
Go
363 lines
8.4 KiB
Go
package db_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/fx/fxtest"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"sneak.berlin/go/neoirc/internal/config"
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/internal/dbtest"
|
|
"sneak.berlin/go/neoirc/internal/globals"
|
|
"sneak.berlin/go/neoirc/internal/logger"
|
|
)
|
|
|
|
const wantBusyTimeoutMS = 5000
|
|
|
|
const (
|
|
// endlessQuery counts the rows of a recursive query that never
|
|
// stops, so it runs until its context ends.
|
|
endlessQuery = `WITH RECURSIVE n(x) AS
|
|
(SELECT 1 UNION ALL SELECT x + 1 FROM n)
|
|
SELECT COUNT(*) FROM n`
|
|
|
|
// endlessQueryTimeout is how long the endless query runs
|
|
// before its context ends. A query whose context ends before
|
|
// it starts is never run, and the pool keeps its connection,
|
|
// so the timeout leaves a loaded host time to start it.
|
|
endlessQueryTimeout = 2 * time.Second
|
|
)
|
|
|
|
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
|
|
// with the default URL's parameters the way the server does,
|
|
// and checks the journal mode and busy timeout it ends up with.
|
|
func TestDefaultDBURLSetsWALAndBusyTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, params, _ := strings.Cut(config.DefaultDBURL, "?")
|
|
dbURL := "file://" + filepath.Join(t.TempDir(), "state.db") +
|
|
"?" + params
|
|
|
|
lifecycle := fxtest.NewLifecycle(t)
|
|
|
|
log, err := logger.New(lifecycle, logger.Params{
|
|
Globals: &globals.Globals{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
database, err := db.New(lifecycle, db.Params{
|
|
Logger: log,
|
|
Config: &config.Config{DBURL: dbURL},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
lifecycle.RequireStart()
|
|
t.Cleanup(lifecycle.RequireStop)
|
|
|
|
var journalMode string
|
|
|
|
err = database.GetDB().QueryRowContext(
|
|
t.Context(), "PRAGMA journal_mode",
|
|
).Scan(&journalMode)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if journalMode != "wal" {
|
|
t.Errorf("journal_mode = %q, want %q", journalMode, "wal")
|
|
}
|
|
|
|
var busyTimeout int
|
|
|
|
err = database.GetDB().QueryRowContext(
|
|
t.Context(), "PRAGMA busy_timeout",
|
|
).Scan(&busyTimeout)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if busyTimeout != wantBusyTimeoutMS {
|
|
t.Errorf(
|
|
"busy_timeout = %d, want %d",
|
|
busyTimeout, wantBusyTimeoutMS,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestForeignKeysOnAfterCancelledQuery cancels a query while it
|
|
// runs, which makes the pool close its only connection and open a
|
|
// new one. A TEMP table lasts only as long as its connection, so
|
|
// its absence shows the connection was replaced. The test then
|
|
// checks that the file database keeps its tables and that the new
|
|
// connection has foreign keys on.
|
|
func TestForeignKeysOnAfterCancelledQuery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, defaultParams, _ := strings.Cut(config.DefaultDBURL, "?")
|
|
|
|
urlParams := map[string]string{
|
|
"default parameters": "?" + defaultParams,
|
|
"no parameters": "",
|
|
}
|
|
|
|
for name, params := range urlParams {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dbURL := "file://" +
|
|
filepath.Join(t.TempDir(), "state.db") + params
|
|
|
|
database, err := startDatabase(t, dbURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = database.GetDB().ExecContext(
|
|
t.Context(), "CREATE TEMP TABLE first_connection (x)",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(
|
|
t.Context(), endlessQueryTimeout,
|
|
)
|
|
defer cancel()
|
|
|
|
_, err = database.GetDB().ExecContext(ctx, endlessQuery)
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("endless query: err = %v", err)
|
|
}
|
|
|
|
tempTables := queryInt(
|
|
t, database, "SELECT COUNT(*) FROM sqlite_temp_schema",
|
|
)
|
|
if tempTables != 0 {
|
|
t.Fatal("the pool kept its first connection")
|
|
}
|
|
|
|
migrations := queryInt(
|
|
t, database, "SELECT COUNT(*) FROM schema_migrations",
|
|
)
|
|
if migrations == 0 {
|
|
t.Error("schema_migrations is empty")
|
|
}
|
|
|
|
foreignKeys := queryInt(t, database, "PRAGMA foreign_keys")
|
|
if foreignKeys != 1 {
|
|
t.Errorf("foreign_keys = %d, want 1", foreignKeys)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewURLKeepsDataAfterCancelledQuery cancels a query on a test
|
|
// database while it runs, which makes the pool close its only
|
|
// connection and open a new one. A TEMP table lasts only as long as
|
|
// its connection, so its absence shows the connection was replaced.
|
|
// The database must still have its tables and rows.
|
|
func TestNewURLKeepsDataAfterCancelledQuery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
database, err := startDatabase(t, dbtest.NewURL(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = database.GetOrCreateChannel(t.Context(), "#test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = database.GetDB().ExecContext(
|
|
t.Context(), "CREATE TEMP TABLE first_connection (x)",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), endlessQueryTimeout)
|
|
defer cancel()
|
|
|
|
_, err = database.GetDB().ExecContext(ctx, endlessQuery)
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("endless query: err = %v", err)
|
|
}
|
|
|
|
tempTables := queryInt(
|
|
t, database, "SELECT COUNT(*) FROM sqlite_temp_schema",
|
|
)
|
|
if tempTables != 0 {
|
|
t.Fatal("the pool kept its first connection")
|
|
}
|
|
|
|
channels := queryInt(t, database, "SELECT COUNT(*) FROM channels")
|
|
if channels != 1 {
|
|
t.Errorf("channels = %d, want 1", channels)
|
|
}
|
|
}
|
|
|
|
// TestDBURLSettingForeignKeysFailsStartup checks that the database
|
|
// refuses to start when DBURL sets foreign_keys itself, however it
|
|
// is spelled.
|
|
func TestDBURLSettingForeignKeysFailsStartup(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pragmas := []string{
|
|
"foreign_keys(0)",
|
|
"foreign_keys(off)",
|
|
"foreign_keys=0",
|
|
"FOREIGN_KEYS(FALSE)",
|
|
" Foreign_Keys = 1",
|
|
}
|
|
|
|
for _, pragma := range pragmas {
|
|
t.Run(pragma, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dbURL := "file://" +
|
|
filepath.Join(t.TempDir(), "state.db") +
|
|
"?_pragma=" + url.QueryEscape(pragma)
|
|
|
|
_, err := startDatabase(t, dbURL)
|
|
if err == nil {
|
|
t.Fatal("the database started")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "DBURL") {
|
|
t.Errorf("error %q does not name DBURL", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPasswordCost checks the bcrypt cost of the hash that SetPassword
|
|
// stores: bcrypt.DefaultCost when db.Params leaves PasswordCost unset,
|
|
// as the server does, and PasswordCost when it is set.
|
|
func TestPasswordCost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
passwordCost int
|
|
wantCost int
|
|
}{
|
|
{name: "unset", wantCost: bcrypt.DefaultCost},
|
|
{
|
|
name: "bcrypt.MinCost",
|
|
passwordCost: bcrypt.MinCost,
|
|
wantCost: bcrypt.MinCost,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lifecycle := fxtest.NewLifecycle(t)
|
|
|
|
log, err := logger.New(lifecycle, logger.Params{
|
|
Globals: &globals.Globals{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
database, err := db.New(lifecycle, db.Params{
|
|
Logger: log,
|
|
Config: &config.Config{DBURL: dbtest.NewURL(t)},
|
|
PasswordCost: test.passwordCost,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
lifecycle.RequireStart()
|
|
t.Cleanup(lifecycle.RequireStop)
|
|
|
|
sessionID, _, _, err := database.CreateSession(
|
|
t.Context(), "costuser", "", "", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = database.SetPassword(t.Context(), sessionID, "password123")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var hash string
|
|
|
|
err = database.GetDB().QueryRowContext(
|
|
t.Context(),
|
|
"SELECT password_hash FROM sessions WHERE id = ?",
|
|
sessionID,
|
|
).Scan(&hash)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cost, err := bcrypt.Cost([]byte(hash))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if cost != test.wantCost {
|
|
t.Errorf("cost = %d, want %d", cost, test.wantCost)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// startDatabase starts a Database for dbURL the way the server does.
|
|
// It returns the error that stopped it from starting, if any.
|
|
func startDatabase(t *testing.T, dbURL string) (*db.Database, error) {
|
|
t.Helper()
|
|
|
|
lifecycle := fxtest.NewLifecycle(t)
|
|
|
|
log, err := logger.New(lifecycle, logger.Params{
|
|
Globals: &globals.Globals{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
database, err := db.New(lifecycle, db.Params{
|
|
Logger: log,
|
|
Config: &config.Config{DBURL: dbURL},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Cleanup(lifecycle.RequireStop)
|
|
|
|
return database, lifecycle.Start(t.Context())
|
|
}
|
|
|
|
// queryInt runs a query that returns a single number.
|
|
func queryInt(t *testing.T, database *db.Database, query string) int {
|
|
t.Helper()
|
|
|
|
var result int
|
|
|
|
err := database.GetDB().QueryRowContext(t.Context(), query).Scan(&result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return result
|
|
}
|