The dashboard leak is closed at the database engine. The black-box
invariant (restricted user + hidden library => absent from every
media-returning surface) went RED->GREEN with this change set; clause-
protected surfaces never regressed, proving enforcement came from the
policies, not the test.
Phase 3 of plan ~/.opencode/plan/server-rls-library-visibility.md:
- schema.sql: idempotent RLS block — ENABLE + FORCE on media_items,
SELECT policy mirroring COALESCE(is_visible,true) exactly, permissive
per-command write stand-ins (FOR ALL would OR into SELECT).
- internal/rls + internal/database/scoped.go: ScopedDB declares the
acting user per statement (LOCAL GUC). DESIGN CORRECTION during
execution: the decorator never refuses — the ENGINE is the fail-loud
layer (policies raise on governed tables; auth lookups and settings,
ungoverned, simply work). A decorator refusal would have been a new
convention gate — plan B thinking — and broke login (chicken-and-egg:
credentials are checked before any identity exists).
- database/schema.go: the migration tx declares a synthetic identity —
schema.sql's dedup DO block SELECTs media_items, which raises once
policies are live (plan A11). Caught by the two-boot gate: boot 1
created the policies after the block ran; boot 2 raised.
- router.go: RLS bridge at BOTH c.Set("user") sites (JWT + device
auth) — echo-local c.Set never reaches the stdlib ctx the DB layer
receives.
- search: the search service's raw DBTX path receives the ScopedDB
itself, never the bare pool.
- harness: the server under test is wired exactly like main.go; the
seed handle is also ScopedDB-wired but carries seedCtx() — a
synthetic identity with no visibility rows — because sqlc :one
inserts use RETURNING, which evaluates the SELECT policy even on
INSERT. Regex UUID-scraping in the invariant replaced with a
structured JSON decode.
- invariant test: RED -> GREEN across every surface incl. View All;
403 pins and admin-scoping pins hold.
277 lines
9.6 KiB
Go
277 lines
9.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bookhoard/internal/config"
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"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
|
|
|
|
dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig)
|
|
require.NoError(t, err, "Failed to connect to test database")
|
|
|
|
db := database.New(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")
|
|
}
|