check / check (push) Waiting to run
The SQLite archive files the database target writes are now managed by internal/archive: file names, rotation, the writer of each target, pruning, renames, the gzipped JSON export, the idle sweep, and the rename lock, which was Handlers.renameMu. The database target stays in internal/delivery and writes through archive.Writers; the engine still closes the writers when it stops, so the stop order is unchanged. The handlers take the rename lock from archive.Archives.RenameLock, and rename and evict through archive.Archives. Exported names drop their Archive prefix in the new package. The tests moved with the code; the two that check a delivery's recorded attempt stay in delivery. Model: opus-5-5
264 lines
7.9 KiB
Go
264 lines
7.9 KiB
Go
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/archive 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)
|
|
}
|
|
|
|
// 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
|
|
// 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)
|
|
}
|