Files
bookhoard/cmd/server/tests/opds_test.go
T
John O'Keefe 431fec05ad feat(security): library visibility enforced at the source; per-library series; annotation ownership; admin user creation
The family-lens hardening: library_visibility was only enforced where
file bytes flowed (download/reader/OPDS) — metadata surfaces leaked
hidden libraries to any authenticated account, and registration was
open by default with no admin-side user creation.

- Series are grouped PER LIBRARY now (same name in two libraries =
  two entries: manga adaptations / LN splits no longer merge);
  /api/series aggregates only the requester's VISIBLE libraries and
  /api/series/books REQUIRES a visible library_id (breaking contract;
  web + app clients updated in the same change).
- /api/media-items, item detail, and the reader content endpoints
  (chapters/panels/outline/THUMBNAILS — page images leaked unguarded
  next to a gated GetPage) all enforce the visible set; resolveLibrary
  no longer honors a hand-typed library_id outside the visible set.
- Annotation/note/bookmark mutations (update/delete/tombstone, sync
  LWW paths incl. conflict resolution) carry ownership predicates:
  another account's bare row id is a 404, not a silent delete.
- Admin user creation: POST /api/auth/users (AdminMiddleware) + a
  Create User card in the admin Users UI — accounts stay creatable
  with self-service registration OFF, which is now the schema default
  (fresh installs; the first-admin bootstrap exception is unchanged).
- Tests: series contract re-pinned (per-library, visibility 403s),
  new ownership + admin-create integration suites, OPDS all=1 test
  now passes an explicit page size (shared-DB growth made the fresh
  books fall off page 1), device-cap helper switched to the admin
  endpoint.
2026-10-13 12:03:01 -04:00

555 lines
20 KiB
Go

package main
import (
"io"
"net/http"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestOPDSEndpoints tests OPDS (Open Publication Distribution System) endpoints
func TestOPDSEndpoints(t *testing.T) {
setup := setupTestServer(t)
// Create ALL media items needed for ALL subtests BEFORE any t.Run (following Kobo pattern)
_ = createTestMediaItemID(t, setup)
bookID1 := createTestMediaItemID(t, setup)
bookID2 := createTestMediaItemID(t, setup)
bookID3 := createTestMediaItemID(t, setup)
client := &http.Client{}
t.Run("GetDeviceCatalog_WithoutDeviceAuth", func(t *testing.T) {
deviceID := uuid.New()
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+deviceID.String()+"/catalog", nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// OPDS endpoints require device authentication via devices.auth_token
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("GetDeviceCatalog_InvalidDeviceID", func(t *testing.T) {
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/catalog", nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return 400 for invalid UUID
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("GetDeviceCatalog_ValidDevice_BearerToken", func(t *testing.T) {
// Create a device with auth token
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-bearer-test")
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/catalog", nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return 200 with catalog (even if empty)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("GetDeviceCatalog_ValidDevice_QueryToken", func(t *testing.T) {
// Create a device with auth token
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "kobo", "opds-query-test")
// Test query parameter authentication
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/catalog?token="+device.AuthToken, nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return 200 with catalog (even if empty)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("SearchDeviceCatalog_InvalidDeviceID", func(t *testing.T) {
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/search?q=test", nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("SearchDeviceCatalog_ValidDevice", func(t *testing.T) {
// Create a device with auth token
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-search-test")
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/search?q=test", nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return 200 (even if empty results)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("GetDeviceNavigation_InvalidDeviceID", func(t *testing.T) {
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/nav", nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("GetDeviceNavigation_ValidDevice", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-nav-test")
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/nav", nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return navigation or 404
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
})
t.Run("DownloadBook_InvalidDeviceID", func(t *testing.T) {
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/download/"+uuid.New().String(), nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("DownloadBook_InvalidBookID", func(t *testing.T) {
deviceID := uuid.New()
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+deviceID.String()+"/download/invalid-uuid", nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("DownloadBook_ValidIDs", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-download-test")
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/download/"+bookID1, nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// May return 404 if device/book not linked, or 500 for file not found
// Should not return 400 (invalid IDs)
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("GetCoverImage_InvalidDeviceID", func(t *testing.T) {
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/cover/"+uuid.New().String(), nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("GetCoverImage_InvalidBookID", func(t *testing.T) {
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/cover/"+uuid.New().String(), nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("GetCoverImage_ValidIDs", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-cover-test")
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/cover/"+bookID2, nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// May return 404 if no cover, but not 400
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("ListFormats_InvalidDeviceID", func(t *testing.T) {
bookID := uuid.New()
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/invalid-uuid/formats/"+bookID.String(), nil)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("ListFormats_ValidDeviceID", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-formats-test")
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/formats/"+bookID3, nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return formats list or 404
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
})
}
// TestOPDSConversion tests on-the-fly conversion for downloads
func TestOPDSConversion(t *testing.T) {
setup := setupTestServer(t)
// Create ALL media items needed for ALL subtests BEFORE any t.Run (following Kobo pattern)
_ = createTestMediaItemID(t, setup)
bookID1 := createTestMediaItemID(t, setup)
bookID2 := createTestMediaItemID(t, setup)
bookID3 := createTestMediaItemID(t, setup)
client := &http.Client{}
t.Run("DownloadKEPUB_FormatParameter", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "kobo", "opds-kepub-test")
// Request KEPUB format
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/download/"+bookID1+"?format=kepub", nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should attempt conversion (may fail if file doesn't exist)
// Important: Should not return 400 for invalid IDs
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("DownloadEPUB_DefaultFormat", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-epub-test")
// Request default format (no format parameter)
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/download/"+bookID2, nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should attempt to download original format
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("Download_UnsupportedFormat", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-unsupported-test")
// Request unsupported format
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/download/"+bookID3+"?format=pdf", nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should handle gracefully (either 400 for unsupported format or 404/500)
assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 600)
})
}
// TestOPDSEdgeCases tests edge cases for OPDS endpoints
func TestOPDSEdgeCases(t *testing.T) {
setup := setupTestServer(t)
_ = createTestMediaItemID(t, setup)
client := &http.Client{}
t.Run("Catalog_EmptyLibrary", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-edge-empty")
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/catalog", nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should return empty catalog, not error
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("Search_SpecialCharacters", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-edge-special")
// Search with special characters
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/search?q=test%20%26%20more", nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should handle special characters
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("Search_EmptyQuery", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-edge-emptyq")
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/opds/devices/"+device.ID.String()+"/search?q=", nil)
httpReq.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(httpReq)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
// Should handle empty query
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500)
})
}
// TestOPDSSearchAcrossLibraries - verify OPDS actually searches across all libraries
func TestOPDSSearchAcrossLibraries(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
// Create two libraries
lib1Resp := createLibrary(t, client, setup, "OPDS Test Lib 1")
lib2Resp := createLibrary(t, client, setup, "OPDS Test Lib 2")
// Add folders
addFolderToLibrary(t, setup, lib1Resp["id"].(string), "/app/uploads")
addFolderToLibrary(t, setup, lib2Resp["id"].(string), "/app/uploads")
// Add books to each library
book1ID := createTestMediaItemIDInLibrary(t, client, setup, lib1Resp["id"].(string), "OPDS Book 1")
book2ID := createTestMediaItemIDInLibrary(t, client, setup, lib2Resp["id"].(string), "OPDS Book 2")
t.Logf("Created book1 in lib1: %s", book1ID)
t.Logf("Created book2 in lib2: %s", book2ID)
// Create device for OPDS access
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "Test OPDS Device", "koreader", "opds-cross-lib-test")
// Search via OPDS (no library_id parameter)
searchURL := setup.Server.URL + "/opds/devices/" + device.ID.String() + "/search?q=OPDS"
t.Logf("OPDS Search URL: %s", searchURL)
req, _ := http.NewRequest("GET", searchURL, nil)
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
t.Logf("OPDS Search Status: %d", resp.StatusCode)
// Read response body
bodyBytes, _ := io.ReadAll(resp.Body)
bodyString := string(bodyBytes)
t.Logf("OPDS response length: %d bytes", len(bodyString))
if resp.StatusCode == 200 {
t.Logf("✅ SUCCESS - OPDS search returns 200 (not 404 like SearchMediaItemsUnified)")
t.Logf(" Response contains 'OPDS Book 1': %v", contains(bodyString, "OPDS Book 1"))
t.Logf(" Response contains 'OPDS Book 2': %v", contains(bodyString, "OPDS Book 2"))
} else {
t.Logf("❌ FAILED - OPDS returned %d", resp.StatusCode)
}
}
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
// catalog is a navigation feed with an "All Books" entry plus one folder per
// visible library, the scoped catalog only lists that library's books, and
// the library-scoped search parameter restricts results to that library.
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
// catalog is a navigation feed with an "All Books" entry plus one folder per
// visible library, the scoped catalog only lists that library's books, and
// the library-scoped search parameter restricts results to that library.
// NOTE: library names deliberately avoid the word "test" — setupTestServer's
// setup-time cleanup deletes every library whose name contains it (and
// setupDeviceTest re-runs that cleanup mid-test).
func TestOPDSLibraryFolders(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
// Two libraries, one distinct book each.
lib1Resp := createLibrary(t, client, setup, "OPDS Folder Lib Alpha")
lib2Resp := createLibrary(t, client, setup, "OPDS Folder Lib Beta")
lib1ID := lib1Resp["id"].(string)
lib2ID := lib2Resp["id"].(string)
// Real directories: the folder API validates that the path exists on the
// host running the server (container path /app/uploads won't exist when
// tests run on the host).
addFolderToLibrary(t, setup, lib1ID, t.TempDir())
addFolderToLibrary(t, setup, lib2ID, t.TempDir())
createTestMediaItemIDInLibrary(t, client, setup, lib1ID, "OPDS Folder Book 1")
createTestMediaItemIDInLibrary(t, client, setup, lib2ID, "OPDS Folder Book 2")
deviceSetup := setupDeviceTest(t)
device := deviceSetup.CreateDevice(t, "OPDS Folders Device", "koreader", "opds-folders-"+uuid.NewString())
base := setup.Server.URL + "/opds/devices/" + device.ID.String()
// Explicit cleanup: these names don't contain "test", so the harness
// cleanup skips them; remove them ourselves. Registered as a defer AFTER
// the setup.Close defer so LIFO ordering runs it while the pool is open.
defer deleteLibraries(t, setup,
pgtype.UUID{Bytes: uuid.MustParse(lib1ID), Valid: true},
pgtype.UUID{Bytes: uuid.MustParse(lib2ID), Valid: true})
getFeed := func(t *testing.T, url string) (int, string, string) {
t.Helper()
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
contentType := resp.Header.Get("Content-Type")
bodyBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, contentType, string(bodyBytes)
}
t.Run("root catalog is a navigation feed with All Books and library folders", func(t *testing.T) {
status, contentType, body := getFeed(t, base+"/catalog")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, contentType, "kind=navigation")
assert.Contains(t, body, "All Books")
assert.Contains(t, body, "OPDS Folder Lib Alpha")
assert.Contains(t, body, "OPDS Folder Lib Beta")
assert.Contains(t, body, "/library/"+lib1ID+"/catalog")
assert.Contains(t, body, "/library/"+lib2ID+"/catalog")
// The nav feed itself must not inline book entries.
assert.NotContains(t, body, "OPDS Folder Book 1")
})
t.Run("all=1 keeps the flat cross-library catalog", func(t *testing.T) {
// Explicit large page: the shared test DB accumulates items, and
// the freshly created books sort last — without a generous
// per_page they fall onto a later page and the assertion reads
// tea leaves instead of the contract.
status, contentType, body := getFeed(t, base+"/catalog?all=1&per_page=200")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, contentType, "kind=acquisition")
assert.Contains(t, body, "OPDS Folder Book 1")
assert.Contains(t, body, "OPDS Folder Book 2")
})
t.Run("scoped catalog lists only its own library's books", func(t *testing.T) {
status, contentType, body := getFeed(t, base+"/library/"+lib1ID+"/catalog")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, contentType, "kind=acquisition")
assert.Contains(t, body, "OPDS Folder Book 1")
assert.NotContains(t, body, "OPDS Folder Book 2")
// Scoped search discovery: template pins the library.
assert.Contains(t, body, "library_id="+lib1ID)
})
t.Run("scoped catalog rejects libraries outside device visibility", func(t *testing.T) {
status, _, _ := getFeed(t, base+"/library/11111111-2222-3333-4444-555555555555/catalog")
assert.Equal(t, http.StatusForbidden, status)
})
t.Run("scoped search restricts results to the library", func(t *testing.T) {
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder&library_id="+lib2ID)
require.Equal(t, http.StatusOK, status)
assert.Contains(t, body, "OPDS Folder Book 2")
assert.NotContains(t, body, "OPDS Folder Book 1")
})
t.Run("scoped search rejects inaccessible library", func(t *testing.T) {
status, _, _ := getFeed(t, base+"/search?q=OPDS&library_id=11111111-2222-3333-4444-555555555555")
assert.Equal(t, http.StatusForbidden, status)
})
t.Run("global search labels entries with their library", func(t *testing.T) {
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, body, "OPDS Folder Book 1")
assert.Contains(t, body, "OPDS Folder Book 2")
assert.Contains(t, body, "OPDS Folder Lib Alpha")
assert.Contains(t, body, "OPDS Folder Lib Beta")
})
}