All checks were successful
check / check (push) Successful in 3m34s
GORM's association upsert copied whole targets rows -- plaintext credential-bearing config -- into the per-webhook event databases with an empty webhook_id. The leak was in updateDeliveryStatus, not the create path: Update leaves Statement.Model pointing at a Delivery whose Target the engine populated, so save_before_associations upserts it. A connection-level callback now appends clause.Associations to Statement.Omits on the create and update chains of every per-webhook connection, so every write path is covered rather than one call site. Existing files are swept on first open: the leaked rows are deleted and the file is VACUUMed, because DELETE alone only unlinks the pages and leaves the credential recoverable in the file's free space. The sweep is recorded in PRAGMA user_version only after the VACUUM returns, so a sweep that fails or is interrupted fails the open and is retried on the next one, rather than being marked done. Encryption of target config at rest is deliberately out of scope and deferred to #212.
439 lines
12 KiB
Go
439 lines
12 KiB
Go
package database_test
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
_ "modernc.org/sqlite"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// testDataDirPerm is the mode the test data directory is created
|
|
// with.
|
|
const testDataDirPerm = 0o750
|
|
|
|
// eventDBDataDir returns a data directory that a WebhookDBManager
|
|
// can be pointed at.
|
|
func eventDBDataDir(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir := filepath.Join(t.TempDir(), "events")
|
|
require.NoError(t, os.MkdirAll(dir, testDataDirPerm))
|
|
|
|
return dir
|
|
}
|
|
|
|
// openRawEventDB opens the per-webhook database file directly,
|
|
// without the manager, so a test can put a file on disk in a state
|
|
// the manager has to cope with, or inspect one afterwards.
|
|
func openRawEventDB(
|
|
t *testing.T, dataDir, webhookID string,
|
|
) *sql.DB {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(
|
|
dataDir, fmt.Sprintf("events-%s.db", webhookID),
|
|
)
|
|
|
|
sqlDB, err := sql.Open(
|
|
"sqlite",
|
|
fmt.Sprintf("file:%s?mode=rwc", path),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
|
|
return sqlDB
|
|
}
|
|
|
|
// eventDBFileBytes reads a per-webhook database file off disk, so a
|
|
// test can assert on what the file itself still holds rather than on
|
|
// what a query returns.
|
|
func eventDBFileBytes(t *testing.T, dataDir, webhookID string) []byte {
|
|
t.Helper()
|
|
|
|
//nolint:gosec // reads a file the test just created under t.TempDir()
|
|
raw, err := os.ReadFile(filepath.Join(
|
|
dataDir, fmt.Sprintf("events-%s.db", webhookID),
|
|
))
|
|
require.NoError(t, err)
|
|
|
|
return raw
|
|
}
|
|
|
|
// eventDBUserVersion returns the PRAGMA user_version of a per-webhook
|
|
// database file, which is the marker purgeTargetRows stamps once it
|
|
// has swept and vacuumed.
|
|
func eventDBUserVersion(t *testing.T, sqlDB *sql.DB) int {
|
|
t.Helper()
|
|
|
|
var version int
|
|
|
|
require.NoError(t, sqlDB.QueryRowContext(
|
|
t.Context(), "PRAGMA user_version",
|
|
).Scan(&version))
|
|
|
|
return version
|
|
}
|
|
|
|
// clearEventDBSweptMarker resets the sweep marker to 0, which is what
|
|
// a file written by a build without the sweep looks like. Tests that
|
|
// seed a leaked row have to create the file through the manager to
|
|
// get the real targets table shape, and that stamps it.
|
|
func clearEventDBSweptMarker(t *testing.T, sqlDB *sql.DB) {
|
|
t.Helper()
|
|
|
|
_, err := sqlDB.ExecContext(t.Context(), "PRAGMA user_version = 0")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// countTargetRows returns the number of rows in the targets table of
|
|
// a per-webhook database file, or -1 if the table does not exist.
|
|
func countTargetRows(t *testing.T, sqlDB *sql.DB) int {
|
|
t.Helper()
|
|
|
|
var tables int
|
|
|
|
require.NoError(t, sqlDB.QueryRowContext(
|
|
t.Context(),
|
|
"SELECT count(*) FROM sqlite_master "+
|
|
"WHERE type = 'table' AND name = 'targets'",
|
|
).Scan(&tables))
|
|
|
|
if tables == 0 {
|
|
return -1
|
|
}
|
|
|
|
var rows int
|
|
|
|
require.NoError(t, sqlDB.QueryRowContext(
|
|
t.Context(), "SELECT count(*) FROM targets",
|
|
).Scan(&rows))
|
|
|
|
return rows
|
|
}
|
|
|
|
// TestOpenPurgesLeakedTargetRows covers the sweep for event
|
|
// databases written by a build that let GORM upsert target rows
|
|
// into them: opening the database clears them, and opening it again
|
|
// is a no-op.
|
|
func TestOpenPurgesLeakedTargetRows(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := eventDBDataDir(t)
|
|
webhookID := uuid.New().String()
|
|
|
|
// Create the file the way the application does, so the targets
|
|
// table has exactly the shape AutoMigrate gives it, then write
|
|
// a leaked row into it the way the association upsert did.
|
|
initial := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err := initial.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, initial.CloseAll())
|
|
|
|
seed := openRawEventDB(t, dataDir, webhookID)
|
|
|
|
_, err = seed.ExecContext(
|
|
t.Context(),
|
|
"INSERT INTO targets "+
|
|
"(id, webhook_id, name, type, config) "+
|
|
"VALUES (?, '', ?, ?, ?)",
|
|
uuid.New().String(),
|
|
"leaked-target",
|
|
"slack",
|
|
`{"webhookUrl":"https://hooks.example/T000/B000/secret"}`,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, countTargetRows(t, seed))
|
|
clearEventDBSweptMarker(t, seed)
|
|
require.NoError(t, seed.Close())
|
|
|
|
mgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err = mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, mgr.CloseAll())
|
|
|
|
check := openRawEventDB(t, dataDir, webhookID)
|
|
assert.Zero(t, countTargetRows(t, check))
|
|
assert.Equal(
|
|
t, 1, eventDBUserVersion(t, check),
|
|
"a completed sweep must mark the file so later opens skip it",
|
|
)
|
|
require.NoError(t, check.Close())
|
|
|
|
// Idempotent: a second open leaves it at zero and does not
|
|
// error.
|
|
again := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err = again.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, again.CloseAll())
|
|
|
|
recheck := openRawEventDB(t, dataDir, webhookID)
|
|
assert.Zero(t, countTargetRows(t, recheck))
|
|
}
|
|
|
|
// TestOpenPurgeRemovesCredentialBytes covers the sweep at the level
|
|
// that matters for a backup handed to someone else: the leaked
|
|
// credential must be gone from the raw bytes of the file, not merely
|
|
// unreachable by query. A bare DELETE unlinks the row and leaves the
|
|
// bytes readable in the free pages, so this fails without the VACUUM
|
|
// in purgeTargetRows.
|
|
func TestOpenPurgeRemovesCredentialBytes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := eventDBDataDir(t)
|
|
webhookID := uuid.New().String()
|
|
credential := "T00000000/B00000000/" + uuid.New().String()
|
|
|
|
initial := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err := initial.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, initial.CloseAll())
|
|
|
|
seed := openRawEventDB(t, dataDir, webhookID)
|
|
|
|
_, err = seed.ExecContext(
|
|
t.Context(),
|
|
"INSERT INTO targets "+
|
|
"(id, webhook_id, name, type, config) "+
|
|
"VALUES (?, '', ?, ?, ?)",
|
|
uuid.New().String(),
|
|
"leaked-target",
|
|
"slack",
|
|
fmt.Sprintf(
|
|
`{"webhookUrl":"https://hooks.example/%s"}`, credential,
|
|
),
|
|
)
|
|
require.NoError(t, err)
|
|
clearEventDBSweptMarker(t, seed)
|
|
require.NoError(t, seed.Close())
|
|
|
|
// The seed has to be in the file for its absence later to mean
|
|
// anything.
|
|
require.True(
|
|
t,
|
|
bytes.Contains(
|
|
eventDBFileBytes(t, dataDir, webhookID),
|
|
[]byte(credential),
|
|
),
|
|
"seeded credential is not in the file, so this test proves nothing",
|
|
)
|
|
|
|
mgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err = mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, mgr.CloseAll())
|
|
|
|
assert.NotContains(
|
|
t,
|
|
string(eventDBFileBytes(t, dataDir, webhookID)),
|
|
credential,
|
|
"leaked credential is still recoverable from the raw file",
|
|
)
|
|
}
|
|
|
|
// TestOpenRevacuumsAfterIncompleteSweep covers the case a row count
|
|
// cannot see: the rows are already deleted but the file was never
|
|
// vacuumed, because an earlier sweep died between the two or its
|
|
// VACUUM failed. The credential bytes are still recoverable, and the
|
|
// unset marker is the only thing that says so, so the next open must
|
|
// vacuum rather than conclude from the empty table that there is
|
|
// nothing to do.
|
|
func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := eventDBDataDir(t)
|
|
webhookID := uuid.New().String()
|
|
credential := "T00000000/B00000000/" + uuid.New().String()
|
|
|
|
initial := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err := initial.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, initial.CloseAll())
|
|
|
|
seed := openRawEventDB(t, dataDir, webhookID)
|
|
|
|
_, err = seed.ExecContext(
|
|
t.Context(),
|
|
"INSERT INTO targets "+
|
|
"(id, webhook_id, name, type, config) "+
|
|
"VALUES (?, '', ?, ?, ?)",
|
|
uuid.New().String(),
|
|
"leaked-target",
|
|
"slack",
|
|
fmt.Sprintf(
|
|
`{"webhookUrl":"https://hooks.example/%s"}`, credential,
|
|
),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Exactly the state an interrupted sweep leaves: rows gone,
|
|
// marker unset, bytes still in the free pages.
|
|
_, err = seed.ExecContext(t.Context(), "DELETE FROM targets")
|
|
require.NoError(t, err)
|
|
require.Zero(t, countTargetRows(t, seed))
|
|
clearEventDBSweptMarker(t, seed)
|
|
require.NoError(t, seed.Close())
|
|
|
|
require.True(
|
|
t,
|
|
bytes.Contains(
|
|
eventDBFileBytes(t, dataDir, webhookID),
|
|
[]byte(credential),
|
|
),
|
|
"the deleted row's bytes must still be in the file, or this "+
|
|
"test proves nothing",
|
|
)
|
|
|
|
mgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err = mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, mgr.CloseAll())
|
|
|
|
assert.NotContains(
|
|
t,
|
|
string(eventDBFileBytes(t, dataDir, webhookID)),
|
|
credential,
|
|
"an interrupted sweep was not retried, so the credential is "+
|
|
"still recoverable from the raw file",
|
|
)
|
|
|
|
check := openRawEventDB(t, dataDir, webhookID)
|
|
assert.Equal(t, 1, eventDBUserVersion(t, check))
|
|
}
|
|
|
|
// TestOpenSkipsSweptDatabase covers the other half of the marker: a
|
|
// file this build created is marked without ever being vacuumed, and
|
|
// a marked file is not swept again.
|
|
func TestOpenSkipsSweptDatabase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := eventDBDataDir(t)
|
|
webhookID := uuid.New().String()
|
|
|
|
mgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err := mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, mgr.CloseAll())
|
|
|
|
marked := openRawEventDB(t, dataDir, webhookID)
|
|
assert.Equal(t, 1, eventDBUserVersion(t, marked))
|
|
|
|
// A marked file is left alone, so a row written into it survives
|
|
// a reopen. Nothing writes target rows any more; this stands in
|
|
// for the sweep having run.
|
|
_, err = marked.ExecContext(
|
|
t.Context(),
|
|
"INSERT INTO targets "+
|
|
"(id, webhook_id, name, type, config) "+
|
|
"VALUES (?, '', ?, ?, ?)",
|
|
uuid.New().String(), "sentinel", "slack", `{}`,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NoError(t, marked.Close())
|
|
|
|
again := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
_, err = again.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, again.CloseAll())
|
|
|
|
check := openRawEventDB(t, dataDir, webhookID)
|
|
assert.Equal(
|
|
t, 1, countTargetRows(t, check),
|
|
"a marked file must not be swept again",
|
|
)
|
|
}
|
|
|
|
// TestOpenSucceedsWithoutTargetsTable covers an existing event
|
|
// database that never grew a targets table. The sweep must not fail
|
|
// startup on it.
|
|
func TestOpenSucceedsWithoutTargetsTable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := eventDBDataDir(t)
|
|
webhookID := uuid.New().String()
|
|
|
|
seed := openRawEventDB(t, dataDir, webhookID)
|
|
|
|
_, err := seed.ExecContext(
|
|
t.Context(),
|
|
"CREATE TABLE events (id text PRIMARY KEY)",
|
|
)
|
|
require.NoError(t, err)
|
|
require.NoError(t, seed.Close())
|
|
|
|
mgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
db, err := mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, db)
|
|
require.NoError(t, mgr.CloseAll())
|
|
}
|
|
|
|
// TestEventDBCreateOmitsAssociations covers the connection-level
|
|
// guard directly: a Delivery carrying its Event and Target in
|
|
// memory, written through the manager's handle, must store only the
|
|
// delivery row.
|
|
func TestEventDBCreateOmitsAssociations(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dataDir := eventDBDataDir(t)
|
|
webhookID := uuid.New().String()
|
|
|
|
mgr := database.NewTestWebhookDBManager(dataDir)
|
|
|
|
db, err := mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
target := database.Target{
|
|
WebhookID: webhookID,
|
|
Name: "leaky-target",
|
|
Type: database.TargetTypeSlack,
|
|
Config: `{"webhookUrl":"https://hooks.example/secret"}`,
|
|
}
|
|
target.ID = uuid.New().String()
|
|
|
|
event := database.Event{
|
|
WebhookID: webhookID,
|
|
EntrypointID: uuid.New().String(),
|
|
Method: "POST",
|
|
Headers: `{}`,
|
|
Body: `{}`,
|
|
}
|
|
event.ID = uuid.New().String()
|
|
|
|
d := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: target.ID,
|
|
Status: database.DeliveryStatusPending,
|
|
Event: event,
|
|
Target: target,
|
|
}
|
|
d.ID = uuid.New().String()
|
|
|
|
require.NoError(t, db.Create(d).Error)
|
|
require.NoError(t, db.Model(d).
|
|
Update("status", database.DeliveryStatusDelivered).
|
|
Error)
|
|
require.NoError(t, mgr.CloseAll())
|
|
|
|
check := openRawEventDB(t, dataDir, webhookID)
|
|
assert.Zero(t, countTargetRows(t, check))
|
|
}
|