package database_test import ( "context" "io/fs" "net/http" "os" "path/filepath" "testing" "github.com/google/uuid" "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" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" ) // ownerOnly is the mode every SQLite file the service owns must have. // Spelled out rather than referencing database.SQLiteFilePerm so the // test fails if the constant itself is loosened. const ownerOnly fs.FileMode = 0o600 // requireOwnerOnly asserts that path exists and is readable and // writable by its owner and by nobody else. func requireOwnerOnly(t *testing.T, path string) { t.Helper() info, err := os.Stat(path) require.NoError(t, err, "%s must exist", path) assert.Equal( t, ownerOnly, info.Mode().Perm(), "%s holds credentials and must not be readable by "+ "anyone but its owner", path, ) } // requireDatabaseSetOwnerOnly asserts the mode of a database file and // of both WAL sidecars. The sidecars carry the same rows as the // database, so tightening only the main file fixes nothing. func requireDatabaseSetOwnerOnly(t *testing.T, dbPath string) { t.Helper() requireOwnerOnly(t, dbPath) requireOwnerOnly(t, dbPath+"-wal") requireOwnerOnly(t, dbPath+"-shm") } // TestMainDatabaseFilesAreOwnerOnly covers the tier the defect was // reported against: webhooker.db holds targets.config in plaintext — // bearer tokens, API keys, Slack webhook URLs — and the session // encryption key. func TestMainDatabaseFilesAreOwnerOnly(t *testing.T) { t.Parallel() lc := fxtest.NewLifecycle(t) l, err := logger.New(lc, logger.LoggerParams{ Globals: &globals.Globals{ Appname: testAppname, Version: testVersion, }, }) require.NoError(t, err) // A directory the application creates itself, not one t.TempDir // made at 0700, so the mode below is the application's. dataDir := filepath.Join(t.TempDir(), "data") db, err := database.New(lc, database.DatabaseParams{ Config: &config.Config{DataDir: dataDir}, Logger: l, }) require.NoError(t, err) ctx := context.Background() require.NoError(t, lc.Start(ctx)) defer func() { require.NoError(t, lc.Stop(ctx)) }() // Write through the real model so the WAL is populated and both // sidecars are on disk while the handle is open. require.NoError(t, db.DB().Create(&database.Webhook{ Name: testWebhookName, }).Error) requireDatabaseSetOwnerOnly( t, filepath.Join(dataDir, database.MainDBFileName), ) // The data directory grants nothing to `other`. Asserted as a // property rather than as an exact 0750, because MkdirAll applies // the ambient umask: the exact mode is the developer's umask as // much as the application's request, and pinning it would make // `make check` pass or fail on where it is run. The group bits are // deliberately left unasserted — deployments may rely on them. info, err := os.Stat(dataDir) require.NoError(t, err) assert.Zero( t, info.Mode().Perm()&0o007, "the data directory must not be world-accessible", ) } // TestPerWebhookEventDatabaseFilesAreOwnerOnly covers the events-*.db // tier. These carry no credential canaries since // https://git.eeqj.de/sneak/webhooker/issues/206, but they hold every // received request body and header. func TestPerWebhookEventDatabaseFilesAreOwnerOnly(t *testing.T) { t.Parallel() mgr, lc := setupTestWebhookDBManager(t) ctx := context.Background() require.NoError(t, lc.Start(ctx)) defer func() { require.NoError(t, lc.Stop(ctx)) }() webhookID := uuid.New().String() db, err := mgr.GetDB(webhookID) require.NoError(t, err) require.NoError(t, db.Create(&database.Event{ WebhookID: webhookID, EntrypointID: uuid.New().String(), Method: http.MethodPost, Body: "{}", }).Error) requireDatabaseSetOwnerOnly(t, mgr.DBPath(webhookID)) } // TestArchiveDatabaseFilesAreOwnerOnly covers the archive-*.db tier. // internal/delivery builds that path and opens it through OpenSQLite, // the same single open path exercised here, so the mode is settled for // all three tiers in one place. func TestArchiveDatabaseFilesAreOwnerOnly(t *testing.T) { t.Parallel() ctx := context.Background() path := filepath.Join( t.TempDir(), "archive-"+uuid.New().String()+".db", ) sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate) require.NoError(t, err) defer func() { require.NoError(t, sqlDB.Close()) }() _, err = sqlDB.ExecContext(ctx, "create table t (id integer)") require.NoError(t, err) requireDatabaseSetOwnerOnly(t, path) } // TestOpenSQLiteTightensFilesLeftWorldReadable is the upgrade case: a // data directory an earlier build left at 0644, including a // developer's own scratch directory, is fixed when it is opened rather // than staying exposed until it is recreated. func TestOpenSQLiteTightensFilesLeftWorldReadable(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, database.MainDBFileName) // A database and both sidecars as the pre-fix build left them. for _, p := range []string{path, path + "-wal", path + "-shm"} { require.NoError(t, os.WriteFile(p, nil, 0o644)) //nolint:gosec // the mode under test } sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate) require.NoError(t, err) require.NoError(t, sqlDB.Close()) requireDatabaseSetOwnerOnly(t, path) } // 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 // empty file materialized here would turn a missing-database error // into a silently empty one. func TestOpenSQLiteExistingModeDoesNotCreateTheFile(t *testing.T) { t.Parallel() ctx := context.Background() path := filepath.Join(t.TempDir(), "absent.db") sqlDB, err := database.OpenSQLite(path, database.SQLiteModeExisting) if err == nil { // sql.Open is lazy: force the connection that fails. require.Error(t, sqlDB.PingContext(ctx)) require.NoError(t, sqlDB.Close()) } _, statErr := os.Stat(path) assert.ErrorIs(t, statErr, fs.ErrNotExist) } // TestReopenAfterRestartKeepsFilesOwnerOnly is the restart case: a // process that closed its files must be able to open them again at // 0600, including through a gorm handle, and the sidecars must come // back at 0600 too rather than at SQLite's own default. func TestReopenAfterRestartKeepsFilesOwnerOnly(t *testing.T) { t.Parallel() ctx := context.Background() dir := t.TempDir() path := filepath.Join(dir, database.MainDBFileName) first, err := database.OpenSQLite(path, database.SQLiteModeCreate) require.NoError(t, err) _, err = first.ExecContext(ctx, "create table t (id integer)") require.NoError(t, err) require.NoError(t, first.Close()) second, err := database.OpenSQLite(path, database.SQLiteModeCreate) require.NoError(t, err) defer func() { require.NoError(t, second.Close()) }() _, err = second.ExecContext(ctx, "insert into t (id) values (1)") require.NoError(t, err) requireDatabaseSetOwnerOnly(t, path) var got int require.NoError(t, second.QueryRowContext(ctx, "select id from t").Scan(&got)) assert.Equal(t, 1, got) }