Files
bookhoard/cmd/server/tests/sync_integration_test.go
T
John O'Keefe 4d697f5fcf test(visibility): full suite green under RLS — sync paths, device-auth bridge, seed pool
Phase 5 (plan §7): the entire integration suite passes with the RLS
policies live. The suite was the triage enumerator the plan predicted:

- device_auth middleware: the THIRD identity bridge — KOReader/Kobo
  device-authenticated requests read media as the DEVICE OWNER
  (plan A9 called this path out; the flip was 404s on sync metadata).
- conflicts/analytics/progress handlers: detached
  context.Background() reads threaded to the request context (the
  requesting user's identity) — 12+ sites; resolve helpers take ctx.
- scanner: ScanFolders self-declares its configured admin identity
  (server maintenance, product-correct for any caller); generic job
  creation stamps the requesting admin.
- harness: setup.DBPool and the sync-test pool declare
  rls.SeedIdentity at SESSION scope via BeforeAcquire — the pool never
  serves a second identity, so no A3 bleed; setup.DB falls back to the
  seed identity (NewSeedDB) for ungoverned-context calls; filter tests
  use seedExec for raw governed-table statements.
- contract pin WIDENED (docs/contract_pins.md 2026-10-29): under RLS,
  item-scoped gates may answer 404 (row invisible — no existence leak)
  as well as 403 (Go-level checks on listings keep strict 403).
- TestWorker_JobStatusTracking — the pre-existing red from the Phase 0
  baseline — passes with the harness seed work; no product change.
2026-10-29 10:14:08 -04:00

284 lines
9.9 KiB
Go

package main
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/rls"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupSyncTestDB creates a database connection for sync integration tests
func setupSyncTestDB(t *testing.T) *database.Queries {
ctx := context.Background()
cfg := config.LoadConfig()
cfg.DatabaseHost = "db"
dbURL := cfg.DatabaseURL()
dbConfig, err := pgxpool.ParseConfig(dbURL)
require.NoError(t, err, "Failed to parse database URL")
dbConfig.MaxConns = 1
// Harness-only session identity (plan §5): see setupTestServer.
dbConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
_, err := conn.Exec(ctx, `SELECT set_config('app.user_id', $1, false)`, rls.SeedIdentity.String())
return err == nil
}
dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig)
require.NoError(t, err, "Failed to connect to test database")
db := database.New(database.NewSeedDB(dbPool))
t.Cleanup(func() {
// Scoped wipes ONLY: reading_history now carries real captured
// sessions for the shared dev accounts, so a table-wide DELETE
// here silently destroyed a live reader's stats data on every
// suite run (2026-10-14). Sync-test sessions belong to the
// test-sync users; deleting those users cascades their rows.
// Same rule for sync_queue: a `WHERE true` wipe could discard a
// REAL device's pending offline entries — scope to the test
// devices (their cascade covers the rest).
_, _ = dbPool.Exec(ctx, "DELETE FROM sync_queue WHERE device_id IN (SELECT id FROM devices WHERE device_name LIKE 'Test %')")
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test-sync%'")
_, _ = dbPool.Exec(ctx, "DELETE FROM media_items WHERE title LIKE 'Test %'")
_, _ = dbPool.Exec(ctx, "DELETE FROM libraries WHERE name LIKE 'Test %'")
_, _ = dbPool.Exec(ctx, "DELETE FROM devices WHERE device_name LIKE 'Test %'")
dbPool.Close()
})
return db
}
func createSyncTestUser(t *testing.T, db *database.Queries) pgtype.UUID {
ctx := context.Background()
hashedPassword := "$2a$10$rKvZHX3lIJ6CpH1lOukQ/xU8j5cH8mYHYP5YGfXllq5hG8y0Ou"
user, err := db.CreateUser(ctx, database.CreateUserParams{
Email: "test-sync@example.com",
Username: "testsyncuser",
PasswordHash: hashedPassword,
FirstName: pgtype.Text{String: "Test", Valid: true},
LastName: pgtype.Text{String: "Sync", Valid: true},
Role: "user",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
require.NoError(t, err)
return user.ID
}
func createSyncTestDevice(t *testing.T, db *database.Queries, userID pgtype.UUID) pgtype.UUID {
ctx := context.Background()
deviceID := uuid.New()
device, err := db.CreateDevice(ctx, database.CreateDeviceParams{
UserID: userID,
DeviceName: "Test Sync Device",
DeviceType: "koreader",
DeviceIdentifier: deviceID.String(),
AuthToken: "test-sync-token-" + deviceID.String(),
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err)
return device.ID
}
func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UUID) pgtype.UUID {
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libraryID,
Title: "Sync Test Book",
Authors: []string{"Test Author"},
FilePath: "/tmp/test.epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
return media.ID
}
func createSyncTestLibrary(t *testing.T, db *database.Queries, userID pgtype.UUID) pgtype.UUID {
ctx := context.Background()
libraryType, err := db.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(t, err, "Should find library type")
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
Name: "Test Sync Library",
Description: pgtype.Text{String: "Test library for sync", Valid: true},
LibraryTypeID: libraryType.ID,
CreatedByAdminID: userID,
})
require.NoError(t, err)
return library.ID
}
// TestSyncFull_Initial tests full sync with initial device state
func TestSyncFull_Initial(t *testing.T) {
ctx := context.Background()
db := setupSyncTestDB(t)
userID := createSyncTestUser(t, db)
libraryID := createSyncTestLibrary(t, db, userID)
deviceID := createSyncTestDevice(t, db, userID)
mediaID := createSyncTestMedia(t, db, libraryID)
// Create reading history entry
now := time.Now()
_, err := db.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
UserID: userID,
MediaItemID: mediaID,
DeviceID: deviceID,
ProgressPercentage: pgtype.Float8{Float64: 25.0, Valid: true},
ReadingSessionStart: pgtype.Timestamptz{Time: now, Valid: true},
PagesRead: pgtype.Int4{Int32: 50, Valid: true},
TimeSpentSeconds: pgtype.Int4{Int32: 300, Valid: true},
})
require.NoError(t, err)
}
// TestSyncIncremental tests incremental sync
func TestSyncIncremental(t *testing.T) {
ctx := context.Background()
db := setupSyncTestDB(t)
userID := createSyncTestUser(t, db)
libraryID := createSyncTestLibrary(t, db, userID)
deviceID := createSyncTestDevice(t, db, userID)
mediaID := createSyncTestMedia(t, db, libraryID)
// Create initial reading history entry
now := time.Now()
_, err := db.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
UserID: userID,
MediaItemID: mediaID,
DeviceID: deviceID,
ProgressPercentage: pgtype.Float8{Float64: 50.0, Valid: true},
ReadingSessionStart: pgtype.Timestamptz{Time: now, Valid: true},
PagesRead: pgtype.Int4{Int32: 100, Valid: true},
TimeSpentSeconds: pgtype.Int4{Int32: 600, Valid: true},
})
require.NoError(t, err)
}
// TestSyncQueueProcessor tests the sync queue processor
func TestSyncQueueProcessor(t *testing.T) {
ctx := context.Background()
db := setupSyncTestDB(t)
userID := createSyncTestUser(t, db)
libraryID := createSyncTestLibrary(t, db, userID)
deviceID := createSyncTestDevice(t, db, userID)
mediaID := createSyncTestMedia(t, db, libraryID)
// Create sync queue item
_, err := db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
DeviceID: deviceID,
MediaItemID: mediaID,
SyncType: "progress",
SyncData: []byte(`{"percentage": 50}`),
Priority: pgtype.Int4{Int32: 5, Valid: true},
MaxAttempts: pgtype.Int4{Int32: 3, Valid: true},
Status: pgtype.Text{String: "pending", Valid: true},
})
require.NoError(t, err)
// Verify queue item was created
items, err := db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
DeviceID: deviceID,
RowLimit: int32(10),
})
require.NoError(t, err)
assert.Equal(t, 1, len(items), "Should have one queue item")
assert.Equal(t, "pending", items[0].Status.String)
}
// TestSyncConflictDetection tests conflict detection
func TestSyncConflictDetection(t *testing.T) {
ctx := context.Background()
db := setupSyncTestDB(t)
userID := createSyncTestUser(t, db)
libraryID := createSyncTestLibrary(t, db, userID)
deviceID := createSyncTestDevice(t, db, userID)
mediaID := createSyncTestMedia(t, db, libraryID)
// Create reading history entry
_, err := db.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
UserID: userID,
MediaItemID: mediaID,
DeviceID: deviceID,
ProgressPercentage: pgtype.Float8{Float64: 50.0, Valid: true},
ReadingSessionStart: pgtype.Timestamptz{Time: time.Now().Add(-1 * time.Hour), Valid: true},
PagesRead: pgtype.Int4{Int32: 100, Valid: true},
TimeSpentSeconds: pgtype.Int4{Int32: 600, Valid: true},
})
require.NoError(t, err)
// Create sync queue item with different percentage (simulating conflict)
_, err = db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
DeviceID: deviceID,
MediaItemID: mediaID,
SyncType: "progress",
SyncData: []byte(`{"percentage": 75}`),
Priority: pgtype.Int4{Int32: 5, Valid: true},
MaxAttempts: pgtype.Int4{Int32: 3, Valid: true},
Status: pgtype.Text{String: "pending", Valid: true},
})
require.NoError(t, err)
// Verify both exist (conflict detection would happen during processing)
items, err := db.ListPendingSyncQueueItems(ctx, database.ListPendingSyncQueueItemsParams{
DeviceID: deviceID,
RowLimit: int32(10),
})
require.NoError(t, err)
assert.Equal(t, 1, len(items), "Should have one queue item")
}
// TestSyncWithDeviceAuth tests device authentication in sync
func TestSyncWithDeviceAuth(t *testing.T) {
setup := setupDeviceTest(t)
defer setup.Server.Close()
// Create a device
device := setup.CreateDevice(t, "Test Sync Device", "koreader", "sync-test-123")
// Verify device exists in database
ctx := context.Background()
pgDeviceID := pgtype.UUID{Bytes: [16]byte(device.ID), Valid: true}
dbDevice, err := setup.DB.GetDevice(ctx, pgDeviceID)
require.NoError(t, err)
assert.Equal(t, "Test Sync Device", dbDevice.DeviceName)
}
// TestSyncEndpoint tests sync endpoint with authentication
func TestSyncEndpoint(t *testing.T) {
setup := setupDeviceTest(t)
defer setup.Server.Close()
device := setup.CreateDevice(t, "Test Sync Device", "koreader", "sync-test-123")
// Test sync endpoint with device token
req := httptest.NewRequest("POST", "/api/sync/koreader/progress", nil)
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
// Should get a response (may be success or error depending on payload)
assert.NotEqual(t, http.StatusUnauthorized, rec.Code, "Should not be unauthorized with valid device token")
}