From 7d424c644c875ca8f9d02e70d11643f8c4d9a46d Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 2 Oct 2026 16:29:23 +0000 Subject: [PATCH] Report or refuse each unusable file webhooker reads (closes #290) Audit of the files webhooker reads configuration or required state from. A missing or zero-length database is reported with the "created a new, empty database" warning and its path: webhooker.db at start, and a per-webhook database at the latest at the next start, since restart recovery now opens the database of every webhook that still exists. The main database's open errors name webhooker.db (https://git.eeqj.de/sneak/webhooker/issues/459). webhooker resetpw refuses a zero-length webhooker.db as it refuses a missing one. A directory in place of a database file or its -wal or -shm is refused, naming it; beside a -shm directory SQLite opened the database read-only without a word. The README says how each case is treated. Model: opus-5-5 --- README.md | 61 +++++++++---- internal/database/bootstrap_banner_test.go | 24 +++++ internal/database/database.go | 19 ++-- internal/database/database_test.go | 25 ++++++ internal/database/retention.go | 4 +- internal/database/sqlite_mode_test.go | 23 +++++ internal/database/sqlite_open.go | 28 +++++- internal/database/webhook_db_manager.go | 93 +++++++++++++++----- internal/database/webhook_db_manager_test.go | 69 +++++++++++++++ internal/delivery/engine.go | 30 +++++-- internal/delivery/engine_integration_test.go | 80 +++++++++++++++++ internal/delivery/terminal_state_test.go | 4 + internal/resetpw/resetpw.go | 11 ++- internal/resetpw/resetpw_test.go | 27 ++++++ 14 files changed, 445 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 98c0c80..33973ab 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,8 @@ directory, read once at startup before anything else looks at the environment. The file is optional and having none is the normal case for a -deployment. A file that is there but cannot be parsed aborts startup +deployment. An empty file is the same as none: it has nothing in it to +apply. A file that is there but cannot be parsed aborts startup with a message naming it, because a single malformed line makes none of the file apply: every variable in it silently reverts to its default, which is exactly the failure [Invalid values abort @@ -564,11 +565,13 @@ its Argon2id hash. There is no second account and no forgot-password flow, so the banner and the reset command below are the only two ways in. -A start that finds no `webhooker.db` in `DATA_DIR` also logs +A start that finds no `webhooker.db` in `DATA_DIR`, or a zero-length +one (which SQLite opens as an empty database), also logs `created a new, empty database` at `WARN`, with the file's path, shortly before the banner. On a deployment that has run before, that -line means `DATA_DIR` was empty, most often because its volume is not -mounted. +line means `webhooker.db` was lost: either the file was missing, most +often because the volume holding `DATA_DIR` is not mounted, or it was +zero-length, as a truncated copy leaves it. #### Recovering a lost admin password @@ -612,7 +615,8 @@ What it will not do: the old password, so a reset underneath it would report a change the service does not honour. - **Create anything.** A `DATA_DIR` that does not exist, or that holds - no `webhooker.db`, is an error rather than a new empty deployment — + no `webhooker.db` or a zero-length one, is an error naming the path + rather than a new empty deployment — a mistyped path must not be built out and then reported as a success. - **Create an account.** A username that does not exist is an error. `resetpw` changes an existing account's password and nothing else. @@ -993,6 +997,16 @@ its sidecars; a killed or crashed instance leaves them, and they must be carried with the `.db`. An archive the service has not opened since a crash keeps that crash's sidecars, even across a later clean stop. +A missing sidecar is therefore normal, and SQLite makes new ones, so a +`-wal` lost from a copy cannot be reported: the transactions it held +are simply gone. SQLite reads a `-wal` up to its first damaged frame, +as after a crash, and rebuilds a damaged `-shm`. A sidecar with the +wrong mode is set back to `0600` when its database is opened. A +directory in place of either is refused then, with an error naming it: +for `webhooker.db` the server and `webhooker resetpw` stop, and an event +or archive database fails as a damaged one does (see +[Database Architecture](#database-architecture)). + Configuration is **not** in `DATA_DIR` — it comes from the environment and from a `.env` file read out of the process working directory. Back that up with your deployment config, separately. @@ -1076,13 +1090,19 @@ with any `-wal`/`-shm` beside it, or wait until there are none. 1. Stop the service. 2. Restore the **whole set together**: `webhooker.db` *and* every - `events-*.db` *and* every `archive-*.db`. A partial restore fails - quietly rather than loudly. Every database is opened `mode=rwc`, so a - missing `events-{uuid}.db` is **created empty** on first access - instead of erroring — the webhook comes back with its configuration - intact and its entire event history silently gone. Event databases - restored without `webhooker.db` are simply orphaned; nothing - references their UUIDs. + `events-*.db` *and* every `archive-*.db`. A restore that leaves out + `webhooker.db` or an `events-*.db` is reported, not refused; one + that leaves out an `archive-*.db` or a `-wal` (step 3) is not + reported at all. Every database is opened `mode=rwc`, so a + missing `events-{uuid}.db` is **created empty**: the webhook comes + back with its configuration intact and its entire event history + gone. The first start after the restore logs + `created a new, empty database` at `WARN` for each such file, with + its path, as it does for a missing `webhooker.db`. A missing + `archive-*.db` is recreated at its target's next delivery without a + warning, since moving one away is a supported workflow. Event + databases restored without `webhooker.db` are simply orphaned; + nothing references their UUIDs. 3. Carry any `*.db-wal` and `*.db-shm` files that are in the backup. They are part of the database, and dropping a `-wal` silently @@ -1945,10 +1965,19 @@ encryption key is generated and stored, and an `admin` user is created. the deliveries per target, kept through retention Per-webhook databases are created automatically when a webhook is -created (and lazily on first access for webhooks that predate this -feature). They are managed by the `WebhookDBManager` component, which +created. They are managed by the `WebhookDBManager` component, which handles connection pooling, lazy opening, migrations, and cleanup. +A per-webhook database that is missing or zero-length later means its +webhook's events and pending deliveries are gone. The next time it is +opened, an empty one is created in its place, so the webhook keeps +receiving, and `created a new, empty database` is logged at `WARN` with +the file's path. Every webhook's database is opened when the service +starts, so this appears at the latest at the first start after the +file was lost. A file there that SQLite cannot open fails that +webhook alone, with an `ERROR` naming the webhook on every access and a +500 to its senders, so one damaged file does not stop the others. + This separation provides: - **Isolation** — a high-volume webhook won't cause lock contention or @@ -2013,7 +2042,9 @@ After each write the archive handle is closed and reopened, debounced to at most once per second, so an operator can move the archive file away for offline archiving without stopping the service; a moved or removed archive file is recreated automatically on -the next write. An optional `expiry` in the target's config JSON (e.g. +the next write. A zero-length archive file is written to as a new +archive: SQLite opens it as an empty database, so it holds nothing to +lose. An optional `expiry` in the target's config JSON (e.g. `{"expiry":"720h"}`) is validated when the target is created — the default (unset or the literal `never`) keeps rows forever — and rows older than the expiry are pruned each time the archive is (re)opened. An diff --git a/internal/database/bootstrap_banner_test.go b/internal/database/bootstrap_banner_test.go index 7f7860d..d775546 100644 --- a/internal/database/bootstrap_banner_test.go +++ b/internal/database/bootstrap_banner_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "log/slog" + "os" "path/filepath" "strings" "testing" @@ -119,3 +120,26 @@ func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) { t, second, created, "an existing database is not new", ) } + +// TestZeroLengthDatabase_IsLoggedAsNew covers what +// https://git.eeqj.de/sneak/webhooker/issues/290 found: SQLite opens a +// zero-length file as an empty database, so a start on one is a first +// start, and it must say so exactly as a start with no file does. +func TestZeroLengthDatabase_IsLoggedAsNew(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, database.MainDBFileName) + require.NoError(t, os.WriteFile(path, nil, database.SQLiteFilePerm)) + + var out bytes.Buffer + + db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil))) + require.NoError(t, err) + require.NoError(t, db.Close()) + + assert.Contains( + t, out.String(), + `level=WARN msg="created a new, empty database" path=`+path, + ) +} diff --git a/internal/database/database.go b/internal/database/database.go index da8bd37..66f4353 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "io" - "io/fs" "log/slog" "os" "path/filepath" @@ -203,8 +202,7 @@ func (d *Database) connectTo(dataDir string) error { // Checked before opening, which creates the file. A DATA_DIR that // is unexpectedly empty -- its volume not mounted, say -- looks // exactly like a first start, so a new database is a warning. - _, statErr := os.Stat(dbPath) - created := errors.Is(statErr, fs.ErrNotExist) + created := missingOrEmpty(dbPath) // Opened through OpenSQLite so this handle carries the same WAL // journaling, busy timeout, immediate-transaction locking, and pool @@ -213,13 +211,15 @@ func (d *Database) connectTo(dataDir string) error { if err != nil { d.log.Error( "failed to open database", + "path", dbPath, "error", err, ) return err } - // Then use it with GORM + // Then use it with GORM. Its errors are SQLite's alone and name no + // file, so the path is added to them here. db, err := gorm.Open(sqlite.Dialector{ Conn: sqlDB, }, &gorm.Config{ @@ -229,10 +229,11 @@ func (d *Database) connectTo(dataDir string) error { if err != nil { d.log.Error( "failed to connect to database", + "path", dbPath, "error", err, ) - return err + return fmt.Errorf("connecting to %s: %w", dbPath, err) } d.db = db @@ -243,8 +244,12 @@ func (d *Database) connectTo(dataDir string) error { d.log.Info("connected to database", "path", dbPath) } - // Run migrations - return d.migrate() + err = d.migrate() + if err != nil { + return fmt.Errorf("migrating %s: %w", dbPath, err) + } + + return nil } func (d *Database) migrate() error { diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 3b6a939..88bf0ae 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -1,9 +1,15 @@ package database_test import ( + "bytes" "context" + "log/slog" + "os" + "path/filepath" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/fx/fxtest" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" @@ -100,3 +106,22 @@ func TestDatabaseConnection(t *testing.T) { ) } } + +// TestOpen_UnreadableDatabaseIsNamed pins +// https://git.eeqj.de/sneak/webhooker/issues/459: when SQLite cannot +// read webhooker.db, the error that stops the server and `webhooker +// resetpw` names the file, not only SQLite's own message. +func TestOpen_UnreadableDatabaseIsNamed(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, database.MainDBFileName) + require.NoError(t, os.WriteFile( + path, bytes.Repeat([]byte("junk"), 1024), database.SQLiteFilePerm, + )) + + _, err := database.Open(dir, slog.New(slog.DiscardHandler)) + require.Error(t, err) + assert.Contains(t, err.Error(), path) + assert.Contains(t, err.Error(), "file is not a database") +} diff --git a/internal/database/retention.go b/internal/database/retention.go index 86544d4..7ee7d75 100644 --- a/internal/database/retention.go +++ b/internal/database/retention.go @@ -184,8 +184,8 @@ func (r *RetentionReaper) sweep(ctx context.Context) { wh := webhooks[i] - // Nothing to reap if the per-webhook database has never - // been created. + // A missing database has nothing to reap. Restart recovery + // reports a lost one (see WebhookDBManager.GetDB). if !r.dbManager.DBExists(wh.ID) { continue } diff --git a/internal/database/sqlite_mode_test.go b/internal/database/sqlite_mode_test.go index 3f75a14..f75c3fa 100644 --- a/internal/database/sqlite_mode_test.go +++ b/internal/database/sqlite_mode_test.go @@ -182,6 +182,29 @@ func TestOpenSQLiteTightensFilesLeftWorldReadable(t *testing.T) { requireDatabaseSetOwnerOnly(t, path) } +// TestOpenSQLiteRefusesADirectorySidecar covers a directory in place +// of -wal or -shm. Beside a -shm directory SQLite opens the database +// read-only without a word, and every write then fails naming no file, +// so the open must stop instead, naming the directory. +func TestOpenSQLiteRefusesADirectorySidecar(t *testing.T) { + t.Parallel() + + for _, suffix := range []string{"-wal", "-shm"} { + t.Run(suffix, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), database.MainDBFileName) + require.NoError(t, os.Mkdir(path+suffix, 0o700)) + + _, err := database.OpenSQLite( + path, database.SQLiteModeCreate, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), path+suffix) + }) + } +} + // TestOpenSQLiteExistingModeDoesNotCreateTheFile guards the mechanism // the fix uses: OpenSQLite now creates the database file itself, and // must not do so for a caller that asked for an existing database. An diff --git a/internal/database/sqlite_open.go b/internal/database/sqlite_open.go index 9003efe..c162a82 100644 --- a/internal/database/sqlite_open.go +++ b/internal/database/sqlite_open.go @@ -7,6 +7,7 @@ import ( "io/fs" "net/url" "os" + "syscall" "time" _ "modernc.org/sqlite" // Pure Go SQLite driver @@ -93,7 +94,8 @@ const ( const SQLiteFilePerm fs.FileMode = 0o600 // reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever -// touches it, and tightens any sidecar already on disk. +// touches it, and tightens any sidecar already on disk. A directory in +// place of any of them is an error naming it. // // The mode has to be settled here rather than by a chmod after opening, // because SQLite picks it: robust_open substitutes @@ -143,7 +145,15 @@ func reserveSQLiteFile(path string, create bool) error { for _, p := range append( []string{path}, sqliteSidecarPaths(path)..., ) { - err := os.Chmod(p, SQLiteFilePerm) + // Chmod accepts a directory, and SQLite opens a database whose + // -shm is one read-only, without a word: every write then + // fails naming no file. + info, err := os.Stat(p) + if err == nil && info.IsDir() { + return fmt.Errorf("securing %s: %w", p, syscall.EISDIR) + } + + err = os.Chmod(p, SQLiteFilePerm) if err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("securing %s: %w", p, err) } @@ -152,6 +162,20 @@ func reserveSQLiteFile(path string, create bool) error { return nil } +// missingOrEmpty reports whether opening path in SQLiteModeCreate +// would start a new, empty database: the file is not there, or it is +// zero-length, which SQLite opens as an empty database. A file left at +// zero length by an interrupted first start or a truncated copy holds +// as little as a missing one, and must be reported the same way. +func missingOrEmpty(path string) bool { + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return true + } + + return err == nil && info.Size() == 0 +} + // sqliteSidecarPaths returns the files SQLite maintains beside a // database under WAL. They carry the same rows as the database itself, // so a fix that tightens only the main file has fixed nothing. diff --git a/internal/database/webhook_db_manager.go b/internal/database/webhook_db_manager.go index adcc3eb..a49b559 100644 --- a/internal/database/webhook_db_manager.go +++ b/internal/database/webhook_db_manager.go @@ -98,34 +98,37 @@ func NewWebhookDBManager( return m, nil } -// GetDB returns the database connection for a webhook, -// creating the database file lazily if it doesn't exist. +// GetDB returns the database connection for a webhook, opening it on +// first use. +// +// The file is made by CreateDB when the webhook is created. One that is +// missing or zero-length here means the webhook's events and pending +// deliveries are gone: an empty database is created in its place so +// the webhook keeps receiving, and that is logged as a warning naming +// the file, as a new main database is. func (m *WebhookDBManager) GetDB( webhookID string, ) (*gorm.DB, error) { - // Fast path: already open - if val, ok := m.dbs.Load(webhookID); ok { - return asGormDB(val, webhookID) - } + return m.getDB(webhookID, false) +} - // Slow path: open the database under the lock, looking in the - // cache again first. A caller that raced another one here then - // waits for its handle instead of opening a second one. +// GetDBIf is GetDB, done only when check reports true. check runs under +// the lock DeleteDB holds while it removes the files, so a caller can +// confirm the webhook still exists and open its database with no delete +// in between. The handle is nil when check reports false. check must +// not call the manager. +func (m *WebhookDBManager) GetDBIf( + webhookID string, check func() (bool, error), +) (*gorm.DB, error) { m.mu.Lock() defer m.mu.Unlock() - if val, ok := m.dbs.Load(webhookID); ok { - return asGormDB(val, webhookID) - } - - db, err := m.openDB(webhookID) - if err != nil { + ok, err := check() + if err != nil || !ok { return nil, err } - m.dbs.Store(webhookID, db) - - return db, nil + return m.getDBLocked(webhookID, false) } // asGormDB returns a value read from the cache as the database @@ -143,12 +146,12 @@ func asGormDB(val any, webhookID string) (*gorm.DB, error) { return db, nil } -// CreateDB explicitly creates a new per-webhook database file -// and runs migrations. +// CreateDB creates a new webhook's database file and runs +// migrations. func (m *WebhookDBManager) CreateDB( webhookID string, ) error { - _, err := m.GetDB(webhookID) + _, err := m.getDB(webhookID, true) return err } @@ -266,6 +269,54 @@ func (m *WebhookDBManager) DBPath( return m.dbPath(webhookID) } +// getDB is GetDB, and CreateDB when isNew is true: the webhook has just +// been created, so a missing file is expected rather than lost. +func (m *WebhookDBManager) getDB( + webhookID string, isNew bool, +) (*gorm.DB, error) { + // Fast path: already open + if val, ok := m.dbs.Load(webhookID); ok { + return asGormDB(val, webhookID) + } + + m.mu.Lock() + defer m.mu.Unlock() + + return m.getDBLocked(webhookID, isNew) +} + +// getDBLocked is getDB's slow path, run with m.mu held. It looks in the +// cache again first: a caller that raced another one to the lock then +// gets its handle instead of opening a second one. +func (m *WebhookDBManager) getDBLocked( + webhookID string, isNew bool, +) (*gorm.DB, error) { + if val, ok := m.dbs.Load(webhookID); ok { + return asGormDB(val, webhookID) + } + + // Checked before opening, which creates the file. See GetDB. + path := m.dbPath(webhookID) + replaced := !isNew && missingOrEmpty(path) + + db, err := m.openDB(webhookID) + if err != nil { + return nil, err + } + + if replaced { + m.log.Warn( + "created a new, empty database", + "webhook_id", webhookID, + "path", path, + ) + } + + m.dbs.Store(webhookID, db) + + return db, nil +} + func (m *WebhookDBManager) dbPath( webhookID string, ) string { diff --git a/internal/database/webhook_db_manager_test.go b/internal/database/webhook_db_manager_test.go index f70f71e..b5954be 100644 --- a/internal/database/webhook_db_manager_test.go +++ b/internal/database/webhook_db_manager_test.go @@ -289,6 +289,75 @@ func TestWebhookDBManager_LazyCreation(t *testing.T) { assert.True(t, mgr.DBExists(webhookID)) } +// A webhook's database is made by CreateDB along with the webhook. One +// that GetDB finds missing or zero-length has lost the webhook's events +// and pending deliveries, so the empty database made in its place is +// logged as a warning naming the file +// (https://git.eeqj.de/sneak/webhooker/issues/290). CreateDB, and +// reopening a database that is there, log no such warning. +func TestWebhookDBManager_LostDatabaseIsLogged(t *testing.T) { + t.Parallel() + + const created = `level=WARN msg="created a new, empty database"` + + open := func( + t *testing.T, prepare func(*database.WebhookDBManager, string), + ) (string, string) { + t.Helper() + + var logs bytes.Buffer + + mgr := database.NewTestWebhookDBManagerWithLogger( + t.TempDir(), + slog.New(slog.NewTextHandler(&logs, nil)), + ) + + webhookID := uuid.New().String() + prepare(mgr, webhookID) + + _, err := mgr.GetDB(webhookID) + require.NoError(t, err) + require.NoError(t, mgr.CloseAll()) + + return logs.String(), + " webhook_id=" + webhookID + " path=" + mgr.DBPath(webhookID) + } + + t.Run("missing", func(t *testing.T) { + t.Parallel() + + logs, fields := open( + t, func(*database.WebhookDBManager, string) {}, + ) + assert.Contains(t, logs, created+fields) + }) + + t.Run("zero-length", func(t *testing.T) { + t.Parallel() + + logs, fields := open( + t, func(mgr *database.WebhookDBManager, webhookID string) { + require.NoError(t, os.WriteFile( + mgr.DBPath(webhookID), nil, database.SQLiteFilePerm, + )) + }, + ) + assert.Contains(t, logs, created+fields) + }) + + t.Run("created with the webhook, then reopened", func(t *testing.T) { + t.Parallel() + + logs, _ := open( + t, func(mgr *database.WebhookDBManager, webhookID string) { + require.NoError(t, mgr.CreateDB(webhookID)) + require.NoError(t, mgr.CloseAll()) + }, + ) + assert.NotContains(t, logs, created) + }) +} + func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) { t.Parallel() diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index 8ac40e0..9fe588f 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -699,10 +699,9 @@ func (e *Engine) recoverInFlight(ctx context.Context) { default: } - if !e.dbManager.DBExists(webhookID) { - continue - } - + // Opened even when its file is missing, so that a lost + // database is reported at start, not when the webhook next + // receives an event, which for a quiet webhook may be never. e.recoverWebhookDeliveries(ctx, webhookID) } } @@ -710,7 +709,24 @@ func (e *Engine) recoverInFlight(ctx context.Context) { func (e *Engine) recoverWebhookDeliveries( ctx context.Context, webhookID string, ) { - webhookDB, err := e.dbManager.GetDB(webhookID) + // The web interface is already serving, so the webhook may have + // been deleted since the list was read. Opening its database then + // would create the file again after the delete removed it. + stillExists := func() (bool, error) { + var count int64 + + err := e.database.DB(). + Model(&database.Webhook{}). + Where("id = ?", webhookID). + Count(&count).Error + if err != nil { + return false, fmt.Errorf("confirming webhook exists: %w", err) + } + + return count > 0, nil + } + + webhookDB, err := e.dbManager.GetDBIf(webhookID, stillExists) if err != nil { e.log.Error( "failed to get webhook database for recovery", @@ -721,6 +737,10 @@ func (e *Engine) recoverWebhookDeliveries( return } + if webhookDB == nil { + return + } + e.recoverPendingDeliveries( ctx, webhookDB, webhookID, ) diff --git a/internal/delivery/engine_integration_test.go b/internal/delivery/engine_integration_test.go index e74d4f3..1676977 100644 --- a/internal/delivery/engine_integration_test.go +++ b/internal/delivery/engine_integration_test.go @@ -1,6 +1,7 @@ package delivery_test import ( + "bytes" "context" "encoding/json" "fmt" @@ -1136,6 +1137,85 @@ func TestRecoverInFlight_WithPendingDeliveries( } } +// TestRecoverInFlight_ReportsAMissingWebhookDatabase covers a webhook +// whose database file is gone, after a partial restore say. Restart +// recovery opens every webhook's database, so the empty one made in its +// place is reported at start, naming the file +// (https://git.eeqj.de/sneak/webhooker/issues/290). +func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) { + t.Parallel() + + mainDB := iMainDB(t) + webhookID := uuid.New().String() + iCreateWebhook(t, mainDB, webhookID, "lost-database") + + var logs bytes.Buffer + + dbMgr := database.NewTestWebhookDBManagerWithLogger( + t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)), + ) + t.Cleanup(func() { _ = dbMgr.CloseAll() }) + + engine := delivery.NewTestEngineWithDB( + database.NewTestDatabase(mainDB), dbMgr, + slog.New(slog.DiscardHandler), + &http.Client{Timeout: 5 * time.Second}, 1, + ) + + engine.ExportRecoverInFlight(context.Background()) + + assert.Contains( + t, logs.String(), + `level=WARN msg="created a new, empty database" webhook_id=`+ + webhookID+" path="+dbMgr.DBPath(webhookID), + ) +} + +// TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead covers a +// webhook deleted from the web interface while restart recovery runs. +// Its database file is gone, and recovery must not create it again. +func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead( + t *testing.T, +) { + t.Parallel() + + mainDB := iMainDB(t) + webhookID := uuid.New().String() + iCreateWebhook(t, mainDB, webhookID, "deleted-during-recovery") + + // The first query to return is recovery's read of the list of + // webhooks. Deleting the webhook right after it puts the delete + // between that read and the opening of the webhook's database. + deleted := false + + require.NoError(t, mainDB.Callback().Query().After("gorm:query"). + Register("delete-after-list", func(*gorm.DB) { + if deleted { + return + } + + deleted = true + + require.NoError(t, mainDB.Delete( + &database.Webhook{}, "id = ?", webhookID, + ).Error) + })) + + dbMgr := database.NewTestWebhookDBManager(t.TempDir()) + t.Cleanup(func() { _ = dbMgr.CloseAll() }) + + engine := delivery.NewTestEngineWithDB( + database.NewTestDatabase(mainDB), dbMgr, + slog.New(slog.DiscardHandler), + &http.Client{Timeout: 5 * time.Second}, 1, + ) + + engine.ExportRecoverInFlight(context.Background()) + + require.True(t, deleted) + assert.False(t, dbMgr.DBExists(webhookID)) +} + // --- HTTP Config with custom headers --- func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { diff --git a/internal/delivery/terminal_state_test.go b/internal/delivery/terminal_state_test.go index 2cf9f4d..5216f00 100644 --- a/internal/delivery/terminal_state_test.go +++ b/internal/delivery/terminal_state_test.go @@ -573,6 +573,8 @@ func TestRecoverPending_TargetDeleted(t *testing.T) { s := newISetup(t) + iCreateWebhook(t, s.MainDB, s.WebhookID, "pending-recovery") + deliveryID := tSeedDeletedTarget( t, s, "gone-while-pending", "http://example.com/hook", database.DeliveryStatusPending, @@ -612,6 +614,8 @@ func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone( s := newISetup(t) + iCreateWebhook(t, s.MainDB, s.WebhookID, "owned-recovery") + deliveryID := tSeedDeletedTarget( t, s, "gone-but-owned", "http://example.com/hook", database.DeliveryStatusPending, diff --git a/internal/resetpw/resetpw.go b/internal/resetpw/resetpw.go index 10b57fa..85e149b 100644 --- a/internal/resetpw/resetpw.go +++ b/internal/resetpw/resetpw.go @@ -308,7 +308,7 @@ func checkDataDir(dir string) error { dbPath := filepath.Join(dir, database.MainDBFileName) - _, err = os.Stat(dbPath) + dbInfo, err := os.Stat(dbPath) switch { case errors.Is(err, fs.ErrNotExist): @@ -319,6 +319,15 @@ func checkDataDir(dir string) error { ) case err != nil: return fmt.Errorf("checking %s: %w", dbPath, err) + case dbInfo.Size() == 0: + // SQLite opens a zero-length file as an empty database, so + // it holds no deployment either, and opening it would write + // an empty schema into it. + return fmt.Errorf( + "%w: %s is zero-length. The admin account is created by "+ + "the first server start", + ErrNoDatabase, dbPath, + ) } return nil diff --git a/internal/resetpw/resetpw_test.go b/internal/resetpw/resetpw_test.go index 77dbd94..922a8ac 100644 --- a/internal/resetpw/resetpw_test.go +++ b/internal/resetpw/resetpw_test.go @@ -377,6 +377,33 @@ func TestMissingDatabaseCreatesNothing(t *testing.T) { ) } +// TestZeroLengthDatabaseCreatesNothing covers a webhooker.db left at +// zero length, as a truncated copy leaves it. SQLite would open it as +// an empty database, so it is refused like a missing one and left as +// it is. +func TestZeroLengthDatabaseCreatesNothing(t *testing.T) { + dir := t.TempDir() + t.Setenv("DATA_DIR", dir) + + dbPath := filepath.Join(dir, database.MainDBFileName) + require.NoError( + t, os.WriteFile(dbPath, nil, database.SQLiteFilePerm), + ) + + code, _, stderr := run(t, newPassword+"\n", operatorUser) + + require.Equal(t, exitFailure, code) + assert.Contains(t, stderr, dbPath) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Len(t, entries, 1, "nothing may be created beside it") + + info, err := os.Stat(dbPath) + require.NoError(t, err) + assert.Zero(t, info.Size(), "nothing may be written into it") +} + // TestUnknownUserFails states the decision: resetpw changes an // existing account's password and never creates an account. A typo in // the username must say so rather than quietly adding a second user. -- 2.54.0