274 lines
11 KiB
Go
274 lines
11 KiB
Go
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)")
|
|
})
|
|
})
|
|
}
|