All checks were successful
check / check (push) Successful in 2m50s
CI run 232 failed TestLoginGuard_SaturatedSemaphoreRefusesRatherThanQueueing and then took the whole internal/middleware test binary down with a SIGSEGV, on a commit whose own gates were green. acquire returns (nil, false) on every refusal path, the assertion on ok was non-fatal, and the next line called the nil release. Two defects sit behind that, and the second one is not confined to the test. acquire selected over a slot send and an already-armed wait timer. Go picks among ready cases uniformly at random, so a process descheduled for longer than the wait sheds a request with slots standing free — under load, which is exactly when shedding a login is least defensible. A non-blocking preamble now takes a free slot before any timer is armed, the same shape lifecycle.waitDone uses to settle its own both-ready race. It cannot let a late arrival barge past a queued waiter: a waiter can only be parked on a full buffer, and a release refills that buffer from the head of the send queue under the channel lock, so the preamble's send fails whenever anyone is waiting. Placing it ahead of the queue admission also stops a request that never waits from occupying a waiter's place. The preamble does not consult ctx, so an already-cancelled request with a free slot is now granted it rather than refused about half the time. That is deliberate and matches waitDone; acquire's doc said otherwise and has been corrected to describe what the code does. The test's third acquire is therefore settled by construction rather than by the wait being long enough, and TestLoginGuard_FreeSlotBeatsAnExpiredWait pins that: 1000 passes with a wait already elapsed on arrival, the worst case scheduling can produce. Without the preamble each pass can go the wrong way and the loop fails within a few passes; measured on the reverted-preamble mutation, pass 2. Assertions whose value is dereferenced afterwards are require, not assert. A sweep of every test file found one sibling of the same shape: webhook_db_manager_test.go checked a slice length non-fatally and indexed it on the next line, so the regression it guards would have surfaced as an index-out-of-range panic through internal/database rather than as a failing test. TestLoginGuard_SemaphoreBoundsConcurrentVerifications used a 10 ms sleep to make two workers overlap and asserted the observed maximum was exactly two. A sleep only makes overlap likely; on a host that can deschedule a goroutine for longer than the sleep the workers serialise and the maximum comes back as one. The slot holders now rendezvous instead, and the barrier opens only once every worker's acquire has returned and any slot it won has been counted — not at the concurrency-th holder, which would fix the lower bound while destroying the upper one the test exists to enforce, since holders would leave before an over-admitting guard's extra workers had been observed. The test no longer sleeps at all. The queue-cap test in the same file held the two tightest wall-clock margins in the repo: it asserted a shed request returned in under 100 ms, timed across a goroutine hand-off, and gave the probe 200 ms to return at all. Both bounded host latency rather than guard behaviour. The elapsed-time assertion is gone, since a shed request is told from a queued one by the queue depth, which is a state fact; the probe's budget is now five seconds against a queue wait of a minute, which only a guard that queues can exhaust. Mutating the queue admission back to a blocking send still fails the test.
372 lines
8.1 KiB
Go
372 lines
8.1 KiB
Go
package database_test
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/globals"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
)
|
|
|
|
func setupTestWebhookDBManager(
|
|
t *testing.T,
|
|
) (*database.WebhookDBManager, *fxtest.Lifecycle) {
|
|
t.Helper()
|
|
|
|
lc := fxtest.NewLifecycle(t)
|
|
|
|
g := &globals.Globals{
|
|
Appname: testAppname,
|
|
Version: testVersion,
|
|
}
|
|
|
|
l, err := logger.New(
|
|
lc,
|
|
logger.LoggerParams{Globals: g},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
dataDir := filepath.Join(t.TempDir(), "events")
|
|
|
|
cfg := &config.Config{
|
|
DataDir: dataDir,
|
|
}
|
|
|
|
mgr, err := database.NewWebhookDBManager(
|
|
lc,
|
|
database.WebhookDBManagerParams{
|
|
Config: cfg,
|
|
Logger: l,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
return mgr, lc
|
|
}
|
|
|
|
func TestWebhookDBManager_CreateAndGetDB(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 should not exist yet
|
|
assert.False(t, mgr.DBExists(webhookID))
|
|
|
|
// Create the DB
|
|
err := mgr.CreateDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
// DB file should now exist
|
|
assert.True(t, mgr.DBExists(webhookID))
|
|
|
|
// Get the DB again (should use cached connection)
|
|
db, err := mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, db)
|
|
|
|
// Verify we can write an event
|
|
event := &database.Event{
|
|
WebhookID: webhookID,
|
|
EntrypointID: uuid.New().String(),
|
|
Method: http.MethodPost,
|
|
Headers: `{"Content-Type":["application/json"]}`,
|
|
Body: `{"test": true}`,
|
|
ContentType: testContentType,
|
|
}
|
|
require.NoError(t, db.Create(event).Error)
|
|
assert.NotEmpty(t, event.ID)
|
|
|
|
// Verify we can read it back
|
|
var readEvent database.Event
|
|
|
|
require.NoError(
|
|
t,
|
|
db.First(&readEvent, "id = ?", event.ID).Error,
|
|
)
|
|
assert.Equal(t, webhookID, readEvent.WebhookID)
|
|
assert.Equal(t, http.MethodPost, readEvent.Method)
|
|
assert.Equal(t, `{"test": true}`, readEvent.Body)
|
|
}
|
|
|
|
func TestWebhookDBManager_DeleteDB(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()
|
|
|
|
// Create the DB and write some data
|
|
require.NoError(t, mgr.CreateDB(webhookID))
|
|
|
|
db, err := mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
event := &database.Event{
|
|
WebhookID: webhookID,
|
|
EntrypointID: uuid.New().String(),
|
|
Method: http.MethodPost,
|
|
Body: `{"test": true}`,
|
|
ContentType: testContentType,
|
|
}
|
|
require.NoError(t, db.Create(event).Error)
|
|
|
|
// Delete the DB
|
|
require.NoError(t, mgr.DeleteDB(webhookID))
|
|
|
|
// File should no longer exist
|
|
assert.False(t, mgr.DBExists(webhookID))
|
|
|
|
// Verify the file is actually gone from disk
|
|
dbPath := mgr.DBPath(webhookID)
|
|
|
|
_, err = os.Stat(dbPath)
|
|
assert.True(t, os.IsNotExist(err))
|
|
}
|
|
|
|
func TestWebhookDBManager_LazyCreation(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()
|
|
|
|
// GetDB should lazily create the database
|
|
db, err := mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, db)
|
|
|
|
// File should now exist
|
|
assert.True(t, mgr.DBExists(webhookID))
|
|
}
|
|
|
|
func TestWebhookDBManager_DeliveryWorkflow(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()
|
|
targetID := uuid.New().String()
|
|
|
|
db, err := mgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
event, delivery := seedDeliveryWorkflow(
|
|
t, db, webhookID, targetID,
|
|
)
|
|
|
|
verifyPendingDeliveries(t, db, event)
|
|
completeDelivery(t, db, delivery)
|
|
verifyNoPending(t, db)
|
|
}
|
|
|
|
func seedDeliveryWorkflow(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
webhookID, targetID string,
|
|
) (*database.Event, *database.Delivery) {
|
|
t.Helper()
|
|
|
|
event := &database.Event{
|
|
WebhookID: webhookID,
|
|
EntrypointID: uuid.New().String(),
|
|
Method: http.MethodPost,
|
|
Headers: `{"Content-Type":["application/json"]}`,
|
|
Body: `{"payload": "test"}`,
|
|
ContentType: testContentType,
|
|
}
|
|
require.NoError(t, db.Create(event).Error)
|
|
|
|
delivery := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: targetID,
|
|
Status: database.DeliveryStatusPending,
|
|
}
|
|
require.NoError(t, db.Create(delivery).Error)
|
|
|
|
return event, delivery
|
|
}
|
|
|
|
func verifyPendingDeliveries(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
event *database.Event,
|
|
) {
|
|
t.Helper()
|
|
|
|
var pending []database.Delivery
|
|
|
|
require.NoError(
|
|
t,
|
|
db.Where(
|
|
"status = ?",
|
|
database.DeliveryStatusPending,
|
|
).Preload("Event").Find(&pending).Error,
|
|
)
|
|
require.Len(t, pending, 1)
|
|
assert.Equal(t, event.ID, pending[0].EventID)
|
|
assert.Equal(t, http.MethodPost, pending[0].Event.Method)
|
|
}
|
|
|
|
func completeDelivery(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
delivery *database.Delivery,
|
|
) {
|
|
t.Helper()
|
|
|
|
result := &database.DeliveryResult{
|
|
DeliveryID: delivery.ID,
|
|
AttemptNum: 1,
|
|
Success: true,
|
|
StatusCode: 200,
|
|
Duration: 42,
|
|
}
|
|
require.NoError(t, db.Create(result).Error)
|
|
|
|
require.NoError(
|
|
t,
|
|
db.Model(delivery).Update(
|
|
"status",
|
|
database.DeliveryStatusDelivered,
|
|
).Error,
|
|
)
|
|
}
|
|
|
|
func verifyNoPending(
|
|
t *testing.T,
|
|
db *gorm.DB,
|
|
) {
|
|
t.Helper()
|
|
|
|
var stillPending []database.Delivery
|
|
|
|
require.NoError(
|
|
t,
|
|
db.Where(
|
|
"status = ?",
|
|
database.DeliveryStatusPending,
|
|
).Find(&stillPending).Error,
|
|
)
|
|
assert.Empty(t, stillPending)
|
|
}
|
|
|
|
func TestWebhookDBManager_MultipleWebhooks(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)) }()
|
|
|
|
webhook1 := uuid.New().String()
|
|
webhook2 := uuid.New().String()
|
|
|
|
// Create DBs for two webhooks
|
|
require.NoError(t, mgr.CreateDB(webhook1))
|
|
require.NoError(t, mgr.CreateDB(webhook2))
|
|
|
|
db1, err := mgr.GetDB(webhook1)
|
|
require.NoError(t, err)
|
|
|
|
db2, err := mgr.GetDB(webhook2)
|
|
require.NoError(t, err)
|
|
|
|
// Write events to each webhook's DB
|
|
event1 := &database.Event{
|
|
WebhookID: webhook1,
|
|
EntrypointID: uuid.New().String(),
|
|
Method: http.MethodPost,
|
|
Body: `{"webhook": 1}`,
|
|
ContentType: testContentType,
|
|
}
|
|
event2 := &database.Event{
|
|
WebhookID: webhook2,
|
|
EntrypointID: uuid.New().String(),
|
|
Method: http.MethodPut,
|
|
Body: `{"webhook": 2}`,
|
|
ContentType: testContentType,
|
|
}
|
|
|
|
require.NoError(t, db1.Create(event1).Error)
|
|
require.NoError(t, db2.Create(event2).Error)
|
|
|
|
// Verify isolation: each DB only has its own events
|
|
var count1 int64
|
|
|
|
db1.Model(&database.Event{}).Count(&count1)
|
|
assert.Equal(t, int64(1), count1)
|
|
|
|
var count2 int64
|
|
|
|
db2.Model(&database.Event{}).Count(&count2)
|
|
assert.Equal(t, int64(1), count2)
|
|
|
|
// Delete webhook1's DB, webhook2 should be unaffected
|
|
require.NoError(t, mgr.DeleteDB(webhook1))
|
|
assert.False(t, mgr.DBExists(webhook1))
|
|
assert.True(t, mgr.DBExists(webhook2))
|
|
|
|
// webhook2's data should still be accessible
|
|
var events []database.Event
|
|
|
|
require.NoError(t, db2.Find(&events).Error)
|
|
|
|
// require, not assert: this is exactly the regression the test
|
|
// guards, so the empty slice is the expected failure, and a
|
|
// non-fatal length check would index into it on the next line and
|
|
// panic the whole package test binary instead of failing here.
|
|
require.Len(t, events, 1)
|
|
assert.Equal(t, "PUT", events[0].Method)
|
|
}
|
|
|
|
func TestWebhookDBManager_CloseAll(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mgr, lc := setupTestWebhookDBManager(t)
|
|
ctx := context.Background()
|
|
require.NoError(t, lc.Start(ctx))
|
|
|
|
// Create a few DBs
|
|
for range 3 {
|
|
require.NoError(
|
|
t,
|
|
mgr.CreateDB(uuid.New().String()),
|
|
)
|
|
}
|
|
|
|
// CloseAll should close all connections without error
|
|
require.NoError(t, mgr.CloseAll())
|
|
|
|
// Stop lifecycle (CloseAll already called)
|
|
require.NoError(t, lc.Stop(ctx))
|
|
}
|