Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5318d767f1 | ||
|
|
6128ef14e6 |
@@ -1889,6 +1889,7 @@ func main() {
|
||||
Pool: deps.DB,
|
||||
},
|
||||
AccessResolver: audiobooks.NewABSAccessResolver(absUserRepo, userStoreProvider),
|
||||
DownloadPolicy: audiobooks.NewABSDownloadPolicy(absUserRepo),
|
||||
Recs: recommendations.NewRepo(deps.DB),
|
||||
Detail: absDetailSvc,
|
||||
SessionMgr: sessionMgr,
|
||||
|
||||
@@ -59,6 +59,16 @@ func (h *Handler) handleFileStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce the account's download privilege. Both /file/{ino} variants are
|
||||
// the offline-save surface (streaming uses the session-scoped public-track
|
||||
// route), so a restricted user must be hard-gated here — mirrors the native
|
||||
// download path's models.User.DownloadAllowed check. Fail closed: a
|
||||
// resolution error denies rather than serving the file.
|
||||
if ok, err := h.downloadAllowed(r.Context(), a.UserID); err != nil || !ok {
|
||||
http.Error(w, "downloads are not permitted for this account", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
contentID := chi.URLParam(r, "libraryItemId")
|
||||
inoStr := chi.URLParam(r, "ino")
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package abs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeDownloadPolicy is an in-memory abs.DownloadPolicy for the file-route
|
||||
// download-privilege tests.
|
||||
type fakeDownloadPolicy struct {
|
||||
allowed map[string]bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDownloadPolicy) DownloadAllowed(_ context.Context, userID string) (bool, error) {
|
||||
if f.err != nil {
|
||||
return false, f.err
|
||||
}
|
||||
return f.allowed[userID], nil
|
||||
}
|
||||
|
||||
// dispatchFileStream invokes handleFileStream with the chi route params and ABS
|
||||
// auth context the bearerAuth middleware would normally inject.
|
||||
func dispatchFileStream(h *Handler, path, contentID, ino, userID string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("libraryItemId", contentID)
|
||||
rctx.URLParams.Add("ino", ino)
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
ctx = context.WithValue(ctx, ctxKey{}, ctxAuth{UserID: userID})
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleFileStream(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// newFileStreamHandler builds a Handler whose media store holds one audio file
|
||||
// for contentID, with the supplied download policy.
|
||||
func newFileStreamHandler(t *testing.T, contentID string, policy DownloadPolicy) *Handler {
|
||||
t.Helper()
|
||||
mediaStore := &filesMediaStore{
|
||||
contentID: contentID,
|
||||
files: []*models.MediaFile{{ID: 1, FilePath: makeTempAudio(t)}},
|
||||
}
|
||||
return New(Dependencies{MediaStore: mediaStore, DownloadPolicy: policy})
|
||||
}
|
||||
|
||||
// TestFileStream_DeniesWhenDownloadDisabled is the regression guard for issue
|
||||
// #141: a restricted user (DownloadAllowed=false) must get 403 from both the
|
||||
// bare /file/{ino} route and the /download variant, and the bytes must not be
|
||||
// served.
|
||||
func TestFileStream_DeniesWhenDownloadDisabled(t *testing.T) {
|
||||
policy := &fakeDownloadPolicy{allowed: map[string]bool{"1": false}}
|
||||
h := newFileStreamHandler(t, "book-1", policy)
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/items/book-1/file/0",
|
||||
"/api/items/book-1/file/0/download",
|
||||
} {
|
||||
rec := dispatchFileStream(h, path, "book-1", "0", "1")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("path %s: status = %d, want 403; body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileStream_AllowsWhenDownloadEnabled confirms the gate does not regress
|
||||
// the happy path: a permitted user still receives the file.
|
||||
func TestFileStream_AllowsWhenDownloadEnabled(t *testing.T) {
|
||||
policy := &fakeDownloadPolicy{allowed: map[string]bool{"1": true}}
|
||||
h := newFileStreamHandler(t, "book-1", policy)
|
||||
|
||||
rec := dispatchFileStream(h, "/api/items/book-1/file/0/download", "book-1", "0", "1")
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want 200/206; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.Len() == 0 {
|
||||
t.Error("expected audio bytes for a permitted download")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileStream_FailsClosedOnPolicyError verifies that a resolver error denies
|
||||
// the download (fail closed) rather than serving the file.
|
||||
func TestFileStream_FailsClosedOnPolicyError(t *testing.T) {
|
||||
policy := &fakeDownloadPolicy{err: errors.New("user store unavailable")}
|
||||
h := newFileStreamHandler(t, "book-1", policy)
|
||||
|
||||
rec := dispatchFileStream(h, "/api/items/book-1/file/0/download", "book-1", "0", "1")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 on resolver error; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileStream_NilPolicyAllows documents that when no DownloadPolicy is wired
|
||||
// (non-production), the handler preserves its prior allow-everything behavior.
|
||||
func TestFileStream_NilPolicyAllows(t *testing.T) {
|
||||
h := newFileStreamHandler(t, "book-1", nil)
|
||||
|
||||
rec := dispatchFileStream(h, "/api/items/book-1/file/0/download", "book-1", "0", "1")
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want 200/206 with nil policy; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginEnvelope_DownloadPermissionReflectsPolicy asserts the login envelope
|
||||
// advertises the real download privilege so compliant clients hide the
|
||||
// offline-save UI for restricted users (issue #141, gap 2).
|
||||
func TestLoginEnvelope_DownloadPermissionReflectsPolicy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
allowed bool
|
||||
}{
|
||||
{"allowed", true},
|
||||
{"denied", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := New(Dependencies{
|
||||
MediaStore: &noopMediaStore{},
|
||||
DownloadPolicy: &fakeDownloadPolicy{allowed: map[string]bool{"7": tc.allowed}},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
env := h.loginEnvelope(req, time.Now(), "7", "Reader", "a", "r")
|
||||
|
||||
user, ok := env["user"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("user is not a map: %T", env["user"])
|
||||
}
|
||||
perms, ok := user["permissions"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("permissions is not a map: %T", user["permissions"])
|
||||
}
|
||||
if got := perms["download"]; got != tc.allowed {
|
||||
t.Errorf("permissions.download = %v, want %v", got, tc.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,22 @@ type Recommender interface {
|
||||
Similar(ctx context.Context, contentID string, limit int) ([]string, error)
|
||||
}
|
||||
|
||||
// DownloadPolicy reports whether an ABS-authenticated account may download
|
||||
// (offline-save) media. It mirrors the native download path, which loads the
|
||||
// user and rejects with download.ErrDownloadNotAllowed when
|
||||
// models.User.DownloadAllowed is false (see internal/download/service.go).
|
||||
//
|
||||
// The ABS file routes (/items/{id}/file/{ino} and the /download variant) are
|
||||
// the offline-save surface; streaming uses session-scoped track URLs and is
|
||||
// unaffected. May be nil in non-production wiring (e.g. tests), in which case
|
||||
// the handler preserves its prior allow-everything behavior — production wires
|
||||
// a real resolver so restricted users are gated.
|
||||
type DownloadPolicy interface {
|
||||
// DownloadAllowed reports whether the account identified by the ABS user
|
||||
// ID (the numeric users.id, as a string) may download media.
|
||||
DownloadAllowed(ctx context.Context, userID string) (bool, error)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config provider
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -226,7 +242,11 @@ type Dependencies struct {
|
||||
Config ConfigProvider
|
||||
Publisher EventPublisher // may be nil
|
||||
Recommender Recommender // may be nil
|
||||
LoginLimiter *LoginLimiter // may be nil — one is created if absent
|
||||
// DownloadPolicy gates the offline-save file routes on the account's
|
||||
// download privilege. May be nil; when unset the handler allows downloads
|
||||
// (preserving prior behavior in non-production wiring).
|
||||
DownloadPolicy DownloadPolicy
|
||||
LoginLimiter *LoginLimiter // may be nil — one is created if absent
|
||||
// InstallID returns the current plugin install ID for building
|
||||
// host-proxy-routable URLs. Defaults to "silo.audiobooks" when nil.
|
||||
InstallID func() string
|
||||
@@ -571,6 +591,18 @@ func (h *Handler) accessFilterFromRequest(r *http.Request) (catalog.AccessFilter
|
||||
return filter, true, err
|
||||
}
|
||||
|
||||
// downloadAllowed reports whether the given ABS user may download (offline-save)
|
||||
// media. When no DownloadPolicy is wired (non-production), it allows downloads
|
||||
// to preserve the handler's prior behavior; production always supplies a real
|
||||
// resolver. Errors from the resolver propagate to the caller, which fails
|
||||
// closed (the file routes treat a resolution error as a denial).
|
||||
func (h *Handler) downloadAllowed(ctx context.Context, userID string) (bool, error) {
|
||||
if h.deps.DownloadPolicy == nil {
|
||||
return true, nil
|
||||
}
|
||||
return h.deps.DownloadPolicy.DownloadAllowed(ctx, userID)
|
||||
}
|
||||
|
||||
func emptyAccessFilter() catalog.AccessFilter {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,18 @@ func (h *Handler) loginEnvelope(
|
||||
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
// Advertise the real download privilege so compliant ABS clients hide the
|
||||
// offline-save UI for restricted users (the file routes enforce it
|
||||
// server-side regardless). On a resolver error, fail closed to false: the
|
||||
// server-side gate is the real guard, and hiding the UI when uncertain is
|
||||
// the safe default. The remaining flags (update/delete/upload/
|
||||
// accessExplicitContent) stay hardcoded true because this compat layer does
|
||||
// not implement those mutations — deriving them is out of scope for #141.
|
||||
downloadPerm, err := h.downloadAllowed(r.Context(), userID)
|
||||
if err != nil {
|
||||
downloadPerm = false
|
||||
}
|
||||
|
||||
user := map[string]any{
|
||||
"id": userID,
|
||||
"username": name,
|
||||
@@ -246,7 +258,7 @@ func (h *Handler) loginEnvelope(
|
||||
"lastSeen": nowMs,
|
||||
"createdAt": nowMs,
|
||||
"permissions": map[string]any{
|
||||
"download": true,
|
||||
"download": downloadPerm,
|
||||
"update": true,
|
||||
"delete": true,
|
||||
"upload": true,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package abs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
var errPolicyUnavailable = errors.New("download policy unavailable")
|
||||
|
||||
// newFeedsHarnessWithPolicy builds the RSS feeds harness with an explicit
|
||||
// DownloadPolicy so the download-privilege gate can be exercised.
|
||||
func newFeedsHarnessWithPolicy(t *testing.T, policy DownloadPolicy, knownItems ...string) (*Handler, *memRSSFeedStore) {
|
||||
t.Helper()
|
||||
known := map[string]*models.MediaItem{}
|
||||
for _, id := range knownItems {
|
||||
known[id] = nil
|
||||
}
|
||||
store := newMemRSSFeedStore()
|
||||
h := New(Dependencies{
|
||||
MediaStore: &stubMediaStore{known: known},
|
||||
RSSFeedStore: store,
|
||||
DownloadPolicy: policy,
|
||||
})
|
||||
return h, store
|
||||
}
|
||||
|
||||
// TestFeed_Open_DeniedWhenDownloadDisabled is part of the issue #141 fix: an
|
||||
// RSS feed mints public, unauthenticated /feed/{slug}/file/{ino} enclosures, so
|
||||
// a restricted user must not be able to create one (it would otherwise bypass
|
||||
// the handleFileStream download gate).
|
||||
func TestFeed_Open_DeniedWhenDownloadDisabled(t *testing.T) {
|
||||
policy := &fakeDownloadPolicy{allowed: map[string]bool{"1": false}}
|
||||
h, store := newFeedsHarnessWithPolicy(t, policy, "book-1")
|
||||
|
||||
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
|
||||
map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if n := len(store.rows); n != 0 {
|
||||
t.Errorf("a feed was persisted (%d rows) despite the download gate", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeed_Open_AllowedWhenDownloadEnabled confirms the gate does not regress
|
||||
// the happy path.
|
||||
func TestFeed_Open_AllowedWhenDownloadEnabled(t *testing.T) {
|
||||
policy := &fakeDownloadPolicy{allowed: map[string]bool{"1": true}}
|
||||
h, _ := newFeedsHarnessWithPolicy(t, policy, "book-1")
|
||||
|
||||
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
|
||||
map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// seedFeed inserts an open feed owned by ownerID directly into the store,
|
||||
// modeling a feed created while the owner could download.
|
||||
func seedFeed(t *testing.T, store *memRSSFeedStore, slug, ownerID, itemID string) {
|
||||
t.Helper()
|
||||
if err := store.CreateFeed(context.Background(), RSSFeed{
|
||||
ID: "feed-" + slug,
|
||||
UserID: ownerID,
|
||||
LibraryItemID: itemID,
|
||||
Slug: slug,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed feed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicFeed_FailsClosedAfterRevocation verifies that an existing feed
|
||||
// stops serving once the owner's download privilege is revoked: both the XML
|
||||
// and the file enclosure must report 404 (hiding the feed) rather than serving.
|
||||
func TestPublicFeed_FailsClosedAfterRevocation(t *testing.T) {
|
||||
policy := &fakeDownloadPolicy{allowed: map[string]bool{"1": false}}
|
||||
h, store := newFeedsHarnessWithPolicy(t, policy, "book-1")
|
||||
seedFeed(t, store, "revoked-feed", "1", "book-1")
|
||||
|
||||
xml := dispatchABSWithParams(http.MethodGet, "/feed/revoked-feed.xml",
|
||||
map[string]string{"slug": "revoked-feed.xml"}, nil, "", "", h.handlePublicFeed)
|
||||
if xml.Code != http.StatusNotFound {
|
||||
t.Errorf("public feed XML status = %d, want 404 after revocation; body=%s", xml.Code, xml.Body.String())
|
||||
}
|
||||
|
||||
file := dispatchABSWithParams(http.MethodGet, "/feed/revoked-feed/file/1",
|
||||
map[string]string{"slug": "revoked-feed", "ino": "1"}, nil, "", "", h.handlePublicFeedFile)
|
||||
if file.Code != http.StatusNotFound {
|
||||
t.Errorf("public feed file status = %d, want 404 after revocation; body=%s", file.Code, file.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicFeed_FailsClosedOnPolicyError ensures a resolver error hides the
|
||||
// feed rather than serving bytes (fail closed).
|
||||
func TestPublicFeed_FailsClosedOnPolicyError(t *testing.T) {
|
||||
policy := &fakeDownloadPolicy{err: errPolicyUnavailable}
|
||||
h, store := newFeedsHarnessWithPolicy(t, policy, "book-1")
|
||||
seedFeed(t, store, "err-feed", "1", "book-1")
|
||||
|
||||
file := dispatchABSWithParams(http.MethodGet, "/feed/err-feed/file/1",
|
||||
map[string]string{"slug": "err-feed", "ino": "1"}, nil, "", "", h.handlePublicFeedFile)
|
||||
if file.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404 on resolver error; body=%s", file.Code, file.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,15 @@ func (h *Handler) handleOpenItemFeed(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "feed store unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
// A public RSS feed exposes /feed/{slug}/file/{ino} enclosures that serve
|
||||
// raw audio bytes without auth (the slug is the capability). That is an
|
||||
// offline-save surface, so a restricted account must not be able to mint
|
||||
// one — otherwise the handleFileStream download gate is trivially bypassed.
|
||||
if ok, err := h.downloadAllowed(r.Context(), a.UserID); err != nil || !ok {
|
||||
http.Error(w, "downloads are not permitted for this account", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
itemID := chi.URLParam(r, "itemId")
|
||||
access, err := h.accessFilterForAuth(r.Context(), a)
|
||||
if err != nil {
|
||||
@@ -165,6 +174,12 @@ func (h *Handler) handlePublicFeed(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "feed get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Mirror handlePublicFeedFile: hide the feed (and its enclosure URLs) once
|
||||
// the owner's download privilege is gone, failing closed on resolver error.
|
||||
if ok, derr := h.downloadAllowed(r.Context(), f.UserID); derr != nil || !ok {
|
||||
http.Error(w, "feed not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), f.LibraryItemID, emptyAccessFilter())
|
||||
if err != nil || item == nil {
|
||||
@@ -224,6 +239,15 @@ func (h *Handler) handlePublicFeedFile(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "feed get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Fail closed if the feed owner's download privilege was revoked after the
|
||||
// feed was created (or a resolver error occurs): an existing feed must not
|
||||
// keep serving bytes once the account loses the download permission. Hide
|
||||
// the feed (404) rather than 403 so the route does not confirm slug
|
||||
// existence to an unauthenticated caller.
|
||||
if ok, derr := h.downloadAllowed(r.Context(), f.UserID); derr != nil || !ok {
|
||||
http.Error(w, "feed not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
inoStr := chi.URLParam(r, "ino")
|
||||
ino, parseErr := strconv.Atoi(inoStr)
|
||||
if parseErr != nil {
|
||||
|
||||
@@ -49,3 +49,40 @@ func (r *ABSAccessResolver) ResolveABSAccess(ctx context.Context, userID, profil
|
||||
ProfileID: scope.ProfileID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ABSDownloadPolicy reports whether an ABS-authenticated account may download
|
||||
// (offline-save) media, backing the abs.DownloadPolicy dependency. It mirrors
|
||||
// the native download service (internal/download/service.go), which loads the
|
||||
// user and checks models.User.DownloadAllowed — keeping the privilege decision
|
||||
// in one place rather than re-deriving it from the access filter.
|
||||
type ABSDownloadPolicy struct {
|
||||
users access.UserRepository
|
||||
}
|
||||
|
||||
// NewABSDownloadPolicy constructs the download-privilege resolver. Returns nil
|
||||
// when no user repository is available; abs.Handler treats a nil policy as
|
||||
// "allow", so callers must wire a real repository to enforce the gate.
|
||||
func NewABSDownloadPolicy(users access.UserRepository) *ABSDownloadPolicy {
|
||||
if users == nil {
|
||||
return nil
|
||||
}
|
||||
return &ABSDownloadPolicy{users: users}
|
||||
}
|
||||
|
||||
// DownloadAllowed loads the account by its numeric ABS user ID and reports its
|
||||
// download privilege. A malformed ID or a load failure returns an error, which
|
||||
// the caller fails closed on (denying the download).
|
||||
func (p *ABSDownloadPolicy) DownloadAllowed(ctx context.Context, userID string) (bool, error) {
|
||||
if p == nil || p.users == nil {
|
||||
return true, nil
|
||||
}
|
||||
uid, err := strconv.Atoi(userID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid ABS user id %q: %w", userID, err)
|
||||
}
|
||||
user, err := p.users.GetByID(ctx, uid)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("loading user %d: %w", uid, err)
|
||||
}
|
||||
return user.DownloadAllowed, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package audiobooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// fakeUserRepo is a minimal access.UserRepository for ABSDownloadPolicy tests.
|
||||
type fakeUserRepo struct {
|
||||
users map[int]*models.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeUserRepo) GetByID(_ context.Context, id int) (*models.User, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
u, ok := f.users[id]
|
||||
if !ok {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func TestABSDownloadPolicy_ReportsUserPrivilege(t *testing.T) {
|
||||
repo := &fakeUserRepo{users: map[int]*models.User{
|
||||
1: {ID: 1, DownloadAllowed: true},
|
||||
2: {ID: 2, DownloadAllowed: false},
|
||||
}}
|
||||
p := NewABSDownloadPolicy(repo)
|
||||
|
||||
if got, err := p.DownloadAllowed(context.Background(), "1"); err != nil || !got {
|
||||
t.Errorf("user 1: got (%v, %v), want (true, nil)", got, err)
|
||||
}
|
||||
if got, err := p.DownloadAllowed(context.Background(), "2"); err != nil || got {
|
||||
t.Errorf("user 2: got (%v, %v), want (false, nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABSDownloadPolicy_InvalidIDErrors(t *testing.T) {
|
||||
p := NewABSDownloadPolicy(&fakeUserRepo{users: map[int]*models.User{}})
|
||||
if _, err := p.DownloadAllowed(context.Background(), "not-a-number"); err == nil {
|
||||
t.Error("expected error for non-numeric user id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestABSDownloadPolicy_LoadErrorPropagates(t *testing.T) {
|
||||
p := NewABSDownloadPolicy(&fakeUserRepo{err: errors.New("db down")})
|
||||
if _, err := p.DownloadAllowed(context.Background(), "1"); err == nil {
|
||||
t.Error("expected load error to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewABSDownloadPolicy_NilRepo(t *testing.T) {
|
||||
if p := NewABSDownloadPolicy(nil); p != nil {
|
||||
t.Errorf("expected nil policy for nil repo, got %#v", p)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,10 @@ type ABSHandlerDeps struct {
|
||||
Settings catalog.SettingsStore // encrypting decorator in production
|
||||
Auth absAuthAdapter // see BuildABSHandler below
|
||||
AccessResolver abs.AccessResolver
|
||||
// DownloadPolicy gates the offline-save file routes on the account's
|
||||
// download privilege (models.User.DownloadAllowed). When nil the ABS
|
||||
// handler allows downloads, so production must supply a real resolver.
|
||||
DownloadPolicy abs.DownloadPolicy
|
||||
// Recs is the recommendations repository used to power the
|
||||
// /items/{id}/similar endpoint via embedding nearest-neighbor search.
|
||||
// Optional; when nil, ABSRecommender falls back to the shared-genre
|
||||
@@ -150,6 +154,7 @@ func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler {
|
||||
TokenStore: tokenStore,
|
||||
CredValidator: deps.Auth,
|
||||
AccessResolver: deps.AccessResolver,
|
||||
DownloadPolicy: deps.DownloadPolicy,
|
||||
Config: configProvider,
|
||||
Publisher: nil, // EventPublisher: no-op stub; Socket.io handles realtime
|
||||
Recommender: buildABSRecommender(deps),
|
||||
|
||||
Reference in New Issue
Block a user