Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/backend-repeatability.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Backend Repeatability

# Weekly proof that the backend tests of the repeatable packages still pass
# when run twice in one process in a random order under the race detector.
# Catches shared in-memory database names and other order/repeat coupling that
# a single `go test` run cannot see. Not a required check: a failure just
# fails the run. Companion guard: backend/internal/testutil/dsn_guard_test.go.

on:
schedule:
- cron: '0 5 * * 4' # Thursdays at 05:00 UTC (no other weekly job runs then)
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read

env:
GOTOOLCHAIN: auto

jobs:
repeatability:
name: Race + repeat + shuffle
runs-on: ubuntu-latest
timeout-minutes: 75
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum

- name: Run repeatability check
working-directory: backend
env:
CGO_ENABLED: 1
run: |
# Derive the shuffle seed from the run id so a failure is replayable:
# re-run locally with the same -shuffle value.
SEED=$(( GITHUB_RUN_ID % 2147483646 + 1 ))
echo "Shuffle seed: ${SEED}"
echo "Shuffle seed: \`${SEED}\`" >> "$GITHUB_STEP_SUMMARY"
export TMPDIR="${RUNNER_TEMP}"
go test -race -count=2 -shuffle="${SEED}" -timeout=60m \
./internal/crowdsec/... ./internal/api/handlers/...
5 changes: 3 additions & 2 deletions backend/internal/api/handlers/auth_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/security"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/testutil"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
Expand All @@ -22,7 +23,7 @@ import (
)

func setupAuthHandler(t *testing.T) (*AuthHandler, *gorm.DB) {
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
dbName := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
_ = db.AutoMigrate(&models.User{}, &models.Setting{})
Expand Down Expand Up @@ -910,7 +911,7 @@ func TestAuthHandler_ChangePassword_Errors(t *testing.T) {

// setupAuthHandlerWithDB creates an AuthHandler with DB access for forward auth tests
func setupAuthHandlerWithDB(t *testing.T) (*AuthHandler, *gorm.DB) {
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
dbName := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{})
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/api/handlers/banner_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"gorm.io/gorm/logger"

"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/testutil"
)

// minimalWebP is a 1×1 pixel valid WebP (RIFF header).
Expand All @@ -38,7 +39,7 @@ var minimalWebP = []byte{

func setupBannerTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := fmt.Sprintf("file:banner_test_%s?mode=memory&cache=shared", t.Name())
dsn := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/api/handlers/benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/testutil"
)

// setupBenchmarkDB creates an in-memory SQLite database for benchmarks
Expand Down Expand Up @@ -345,7 +346,7 @@ func BenchmarkSecurityHandler_GetStatus_Parallel(b *testing.B) {
func BenchmarkSecurityHandler_ListDecisions_Parallel(b *testing.B) {
gin.SetMode(gin.ReleaseMode)
// Use file-based SQLite with WAL mode for parallel testing
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_journal_mode=WAL"), &gorm.Config{
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(b, "_journal_mode=WAL")), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package handlers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
Expand All @@ -19,6 +18,7 @@ import (
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/testutil"
)

const exportTestPassword = "correct-password"
Expand All @@ -37,7 +37,7 @@ type exportHarness struct {
func newExportHarness(t *testing.T, guard PasswordAttemptGuard, preMiddleware ...gin.HandlerFunc) *exportHarness {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}, &models.Setting{}))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ import (

"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/testutil"
)

// --- Delete UUID path with backup service ---

func TestDelete_UUID_WithBackup_Success(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))

Expand All @@ -50,7 +51,7 @@ func TestDelete_UUID_WithBackup_Success(t *testing.T) {

func TestDelete_UUID_NotFound(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))

Expand All @@ -70,7 +71,7 @@ func TestDelete_UUID_NotFound(t *testing.T) {

func TestDelete_UUID_InUse(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))

Expand All @@ -94,7 +95,7 @@ func TestDelete_UUID_InUse(t *testing.T) {

func TestDelete_UUID_BackupLowSpace(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))

Expand All @@ -119,7 +120,7 @@ func TestDelete_UUID_BackupLowSpace(t *testing.T) {

func TestDelete_UUID_BackupSpaceCheckError(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))

Expand All @@ -146,7 +147,7 @@ func TestDelete_UUID_BackupSpaceCheckError(t *testing.T) {

func TestDelete_UUID_BackupCreateError(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))

Expand Down Expand Up @@ -174,7 +175,7 @@ func TestDelete_UUID_BackupCreateError(t *testing.T) {

func TestDelete_UUID_WithNotification(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.Setting{}, &models.Notification{}, &models.NotificationProvider{}))

Expand All @@ -199,7 +200,7 @@ func TestDelete_UUID_WithNotification(t *testing.T) {

func TestValidate_Success(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))

Expand Down Expand Up @@ -230,7 +231,7 @@ func TestValidate_Success(t *testing.T) {

func TestValidate_InvalidCert(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))

Expand Down Expand Up @@ -259,7 +260,7 @@ func TestValidate_InvalidCert(t *testing.T) {

func TestValidate_NoCertFile(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))

Expand All @@ -279,7 +280,7 @@ func TestValidate_NoCertFile(t *testing.T) {

func TestValidate_WithKeyAndChain(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))

Expand Down Expand Up @@ -323,7 +324,7 @@ func TestValidate_WithKeyAndChain(t *testing.T) {
// --- Get handler DB error (non-NotFound) ---

func TestGet_DBError(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
// Deliberately don't migrate - any query will fail with "no such table"

Expand All @@ -345,7 +346,7 @@ func TestGet_DBError(t *testing.T) {

func TestExport_IncludeKey_MissingPassword(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
dsn := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
Expand All @@ -367,7 +368,7 @@ func TestExport_IncludeKey_MissingPassword(t *testing.T) {

func TestExport_CertNotFound(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
dsn := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
Expand All @@ -388,7 +389,7 @@ func TestExport_CertNotFound(t *testing.T) {

func TestExport_ServiceError(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
dsn := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
Expand Down Expand Up @@ -416,7 +417,7 @@ func TestExport_ServiceError(t *testing.T) {

func TestDelete_NumericID_UsageCheckError(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
dsn := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) // no ProxyHost → IsCertificateInUse fails
Expand All @@ -436,7 +437,7 @@ func TestDelete_NumericID_UsageCheckError(t *testing.T) {

func TestDelete_NumericID_LowDiskSpace(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
dsn := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
Expand All @@ -463,7 +464,7 @@ func TestDelete_NumericID_LowDiskSpace(t *testing.T) {

func TestDelete_NumericID_BackupError(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
dsn := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
Expand All @@ -490,7 +491,7 @@ func TestDelete_NumericID_BackupError(t *testing.T) {

func TestDelete_NumericID_DeleteError(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
dsn := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.SSLCertificate{}))
Expand All @@ -513,7 +514,7 @@ func TestDelete_NumericID_DeleteError(t *testing.T) {

func TestDelete_UUID_UsageCheckInternalError(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
dsn := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) // no ProxyHost → IsCertificateInUse fails
Expand All @@ -538,7 +539,7 @@ func TestDelete_UUID_UsageCheckInternalError(t *testing.T) {
// --- sendDeleteNotification: rate limit ---

func TestSendDeleteNotification_RateLimit(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
dsn := testutil.MemoryDSN(t)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
Expand Down Expand Up @@ -574,7 +575,7 @@ func TestUpdate_EmptyUUID(t *testing.T) {
// --- Update: DB error (non-ErrCertNotFound) → lines 223-225 ---

func TestUpdate_DBError(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
require.NoError(t, err)
// Deliberately no AutoMigrate → ssl_certificates table absent → "no such table" error

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import (

"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/testutil"
)

// TestCertificateHandler_Delete_RequiresAuth tests that delete requires authentication
func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
Expand Down Expand Up @@ -45,7 +46,7 @@ func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) {

// TestCertificateHandler_List_RequiresAuth tests that list requires authentication
func TestCertificateHandler_List_RequiresAuth(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
Expand Down Expand Up @@ -74,7 +75,7 @@ func TestCertificateHandler_List_RequiresAuth(t *testing.T) {

// TestCertificateHandler_Upload_RequiresAuth tests that upload requires authentication
func TestCertificateHandler_Upload_RequiresAuth(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
Expand Down Expand Up @@ -103,7 +104,7 @@ func TestCertificateHandler_Upload_RequiresAuth(t *testing.T) {

// TestCertificateHandler_Delete_DiskSpaceCheck tests the disk space check before backup
func TestCertificateHandler_Delete_DiskSpaceCheck(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(testutil.MemoryDSN(t)), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
Expand Down
Loading
Loading