package main // rls_visibility_test.go — the PERMANENT black-box auditor for per-user // library visibility (the "family lens"). // // The one sentence it asserts, in the user's language: // // A user with a library hidden sees NOTHING from that library, on // ANY media-returning surface — while an admin still sees it (the // hide is per-user, never global). // // Implementation-agnostic on purpose: it speaks only HTTP + response // bodies, so it stays valid regardless of HOW enforcement is done // (per-query visible_library_ids clauses today, Postgres RLS in the // RLS rollout, anything tomorrow). It is the auditor that makes a // forgotten surface a failing test instead of a silent leak. // // Red/green history: RED at introduction on the dashboard-section and // collection-detail (mobile View All) surfaces, whose queries never // carried the visible_library_ids clause; GREEN once enforcement // covers every surface (plan: // ~/.opencode/plan/server-rls-library-visibility.md). import ( "context" "fmt" "net/http" "regexp" "strings" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "bookhoard/internal/database" ) // uuidInBody finds candidate collection ids in a dashboard-sections // body so the View-All detail endpoint can be probed without coupling // the test to the response shape. var rlsUUIDPattern = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) func TestRlsInvariant(t *testing.T) { setup := setupTestServer(t) ctx := context.Background() adminToken := setup.Token regularToken := setup.RegularToken regularID := getRegularUserID(t, setup.DB) // Unique markers: one for the hidden library's whole subtree // (library name + both book titles — a single string that must // appear NOWHERE), one for the visible control book. hiddenMarker := fmt.Sprintf("RLSHIDE%s", uuid.New().String()[:8]) controlMarker := fmt.Sprintf("RLSCTRL%s", uuid.New().String()[:8]) libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks") require.NoError(t, err, "ebooks library type should exist") createLib := func(name string) database.Libraries { lib, cerr := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{ Name: name, Description: pgtype.Text{String: "rls invariant test library", Valid: true}, LibraryTypeID: libType.ID, CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true}, }) require.NoError(t, cerr, "create library %q", name) // Deleting the library cascades its media_items AND its // library_visibility rows (FK ON DELETE CASCADE) — one cleanup // covers the whole subtree. t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) }) return lib } libH := createLib("RLS Test Hidden Library " + hiddenMarker) libV := createLib("RLS Test Visible Library " + controlMarker) createBook := func(libraryID pgtype.UUID, title string) { _, cerr := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: libraryID, Title: title, Authors: []string{"RLS Invariant Author"}, FilePath: fmt.Sprintf("/tmp/rls-invariant/%s-%s.epub", title, uuid.New().String()), FileSize: pgtype.Int8{Int64: 1024, Valid: true}, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, }) require.NoError(t, cerr, "create book %q", title) } createBook(libH.ID, hiddenMarker+" Hidden Alpha") createBook(libH.ID, hiddenMarker+" Hidden Beta") createBook(libV.ID, controlMarker+" Visible Gamma") // Hide libH from the REGULAR user — the same row the admin // visibility UI writes (harness idiom: SetLibraryVisibility). _, err := setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{ UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true}, LibraryID: libH.ID, IsVisible: false, }) require.NoError(t, err, "hide libH from regular user") // OPDS device owned by the REGULAR user (OPDS identity = device owner). opdsToken := "dev_" + uuid.New().String() device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{ UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true}, DeviceName: "RLS Invariant OPDS Device", DeviceType: "koreader", DeviceIdentifier: "rls-invariant-" + uuid.New().String()[:8], AuthToken: opdsToken, SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, AutoSync: pgtype.Bool{Bool: true, Valid: true}, SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true}, DeviceMetadata: []byte("{}"), }) require.NoError(t, err, "create OPDS device for regular user") t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, device.ID) }) rlsGet := func(token, path string) (int, string) { req, _ := http.NewRequest(http.MethodGet, setup.Server.URL+path, nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) require.NoError(t, err, "GET %s", path) defer func() { _ = resp.Body.Close() }() var sb strings.Builder buf := make([]byte, 32*1024) for { n, err := resp.Body.Read(buf) if n > 0 { sb.Write(buf[:n]) } if err != nil { break } } return resp.StatusCode, sb.String() } // Surfaces that render media TITLES (books appear by name). titleSurfaces := []struct { name string path string auth string }{ {"media-items list", "/api/media-items?limit=500", regularToken}, {"dashboard sections", "/api/dashboard/sections?limit=500", regularToken}, {"opds catalog", "/opds/devices/" + device.ID.String() + "/catalog", opdsToken}, } // Surfaces that return media-adjacent data (counts, series names, // aggregates) — hidden-subtree leakage would show up as marker // series names / inventory rows, not book titles. aggregateSurfaces := []struct { name string path string auth string }{ {"collections list", "/api/collections", regularToken}, {"series list", "/api/series", regularToken}, {"stats summary", "/api/stats/summary", regularToken}, {"stats library-usage", "/api/stats/library-usage", regularToken}, } t.Run("restricted_user_hidden_library_absent_everywhere", func(t *testing.T) { for _, s := range titleSurfaces { t.Run(s.name, func(t *testing.T) { code, body := rlsGet(s.auth, s.path) require.Equal(t, http.StatusOK, code, "surface %s must be authorized (a 401 here is a harness bug, not a pass)", s.path) // THE INVARIANT: nothing from the hidden subtree — // neither its books nor the library itself. assert.NotContains(t, body, hiddenMarker, "LEAK: hidden library content surfaced on %s", s.path) // Control: the visible library's book IS present — // guards against false greens from empty/anonymous // responses. if s.name != "opds catalog" { // root catalog may nest books behind library subsections assert.Contains(t, body, controlMarker, "control book missing from %s — response is not listing media (false-green guard)", s.path) } }) } for _, s := range aggregateSurfaces { t.Run(s.name, func(t *testing.T) { code, body := rlsGet(s.auth, s.path) require.Equal(t, http.StatusOK, code, "surface %s must be authorized", s.path) assert.NotContains(t, body, hiddenMarker, "LEAK: hidden library content surfaced on %s", s.path) }) } }) t.Run("restricted_user_dashboard_scoped_to_hidden_library", func(t *testing.T) { code, body := rlsGet(regularToken, "/api/dashboard/sections?library_id="+uuid.UUID(libH.ID.Bytes).String()) assert.NotContains(t, body, hiddenMarker, "LEAK: dashboard scoped to the hidden library leaked its content (status %d)", code) }) t.Run("restricted_user_collection_detail_view_all", func(t *testing.T) { // The mobile View All path: GET /api/collections/{id} for every // collection the dashboard references (system sections included). _, sectionsBody := rlsGet(regularToken, "/api/dashboard/sections?limit=500") seen := map[string]bool{} for _, id := range rlsUUIDPattern.FindAllString(sectionsBody, -1) { if seen[id] { continue } seen[id] = true code, body := rlsGet(regularToken, "/api/collections/"+id) if code != http.StatusOK { continue // not a collection id (or not visible) — absence trivially holds } assert.NotContains(t, body, hiddenMarker, "LEAK: collection detail %s (View All surface) returned hidden-library content", id) } }) t.Run("restricted_user_explicit_hidden_library_scoped_requests_rejected", func(t *testing.T) { // Pinned contract (AGENTS.md family-lens rule): naming a hidden // library explicitly is a 403, never a silent pass. libID := uuid.UUID(libH.ID.Bytes).String() for _, path := range []string{ "/api/media-items?library_id=" + libID, "/api/series?library_id=" + libID, "/api/series/books?library_id=" + libID + "&name=RLS", } { code, body := rlsGet(regularToken, path) assert.Equal(t, http.StatusForbidden, code, "scoped request %s must be 403 for a hidden library (got %d)", path, code) assert.NotContains(t, body, hiddenMarker) } }) t.Run("admin_still_sees_hidden_library_user_scoped_not_global", func(t *testing.T) { // The hide is PER-USER: an admin (no hidden rows) must still see // everything. This catches "fixing" the leak by hiding the // library globally. for _, s := range []struct{ name, path string }{ {"media-items list", "/api/media-items?limit=500"}, {"dashboard sections", "/api/dashboard/sections?limit=500"}, } { t.Run(s.name, func(t *testing.T) { code, body := rlsGet(adminToken, s.path) require.Equal(t, http.StatusOK, code) assert.Contains(t, body, hiddenMarker, "admin must still see the hidden library on %s (hide is user-scoped)", s.path) }) } t.Run("opds catalog", func(t *testing.T) { // OPDS identity = device owner; an ADMIN-owned device sees all. adminDevToken := "dev_" + uuid.New().String() adminID := getTestUserID(t, setup.DB) adminDevice, derr := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{ UserID: pgtype.UUID{Bytes: [16]byte(adminID), Valid: true}, DeviceName: "RLS Invariant Admin OPDS Device", DeviceType: "koreader", DeviceIdentifier: "rls-invariant-admin-" + uuid.New().String()[:8], AuthToken: adminDevToken, SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, AutoSync: pgtype.Bool{Bool: true, Valid: true}, SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true}, DeviceMetadata: []byte("{}"), }) require.NoError(t, derr, "create OPDS device for admin") t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, adminDevice.ID) }) code, body := rlsGet(adminDevToken, "/opds/devices/"+adminDevice.ID.String()+"/catalog") require.Equal(t, http.StatusOK, code) assert.Contains(t, body, hiddenMarker, "admin's OPDS catalog must still expose the hidden library (user-scoped hide)") }) }) }