diff --git a/internal/audiobooks/abs/collections_handler.go b/internal/audiobooks/abs/collections_handler.go index 150bd378..080c0d01 100644 --- a/internal/audiobooks/abs/collections_handler.go +++ b/internal/audiobooks/abs/collections_handler.go @@ -142,3 +142,30 @@ func (h *Handler) handleListCollections(w http.ResponseWriter, r *http.Request) // chiURLID is a tiny shim around chi.URLParam(r, "id") so handler call // sites read uniformly. Inlined where unambiguous. func chiURLID(r *http.Request) string { return chi.URLParam(r, "id") } + +// handleGetCollection — GET /collections/{id}. +// Owner gets full-shape; non-owner gets full-shape only when isPublic. +// Otherwise 404 (no existence leak — indistinguishable from real +// not-found). +func (h *Handler) handleGetCollection(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if h.deps.CollectionStore == nil { + http.Error(w, "collection not found", http.StatusNotFound) + return + } + c, err := h.deps.CollectionStore.GetCollection(r.Context(), chiURLID(r)) + if errors.Is(err, ErrNotFound) || (err == nil && c.UserID != a.UserID && !c.IsPublic) { + http.Error(w, "collection not found", http.StatusNotFound) + return + } + if err != nil { + slog.Error("abs collection get failed", "err", err) + http.Error(w, "collection get failed", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, h.collectionFullShape(r, c)) +} diff --git a/internal/audiobooks/abs/collections_handler_test.go b/internal/audiobooks/abs/collections_handler_test.go index 059e0874..5c115f14 100644 --- a/internal/audiobooks/abs/collections_handler_test.go +++ b/internal/audiobooks/abs/collections_handler_test.go @@ -301,3 +301,75 @@ func TestCollection_List_ProfileIsolation(t *testing.T) { t.Errorf("profile B sees %d collections, want 0", len(list)) } } + +// createCollectionForUser is a tiny helper that POSTs a collection and +// returns its id. Used by tests that need to seed a row. +func createCollectionForUser(t *testing.T, hb *collectionsHarness, userID, profileID, body string) string { + t.Helper() + rec := dispatchABSWithParams(http.MethodPost, "/api/collections", nil, []byte(body), userID, profileID, hb.H.handleCreateCollection) + if rec.Code != http.StatusOK { + t.Fatalf("seed POST status = %d; body=%s", rec.Code, rec.Body.String()) + } + var got map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &got) + id, _ := got["id"].(string) + if id == "" { + t.Fatalf("seed POST returned no id; body=%s", rec.Body.String()) + } + return id +} + +func TestCollection_Get_Owner_ReturnsFullShape(t *testing.T) { + hb := newCollectionsHarness(t) + id := createCollectionForUser(t, hb, "1", "", `{"name":"mine"}`) + + rec := dispatchABSWithParams(http.MethodGet, "/api/collections/"+id, map[string]string{"id": id}, nil, "1", "", hb.H.handleGetCollection) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var got map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &got) + if got["name"] != "mine" { + t.Errorf("name = %v, want 'mine'", got["name"]) + } + books, ok := got["books"].([]any) + if !ok { + t.Errorf("books missing on full-shape response: %v", got) + } + if len(books) != 0 { + t.Errorf("books len = %d, want 0 for freshly created", len(books)) + } +} + +func TestCollection_Get_NonOwner_Private_404(t *testing.T) { + hb := newCollectionsHarness(t) + id := createCollectionForUser(t, hb, "1", "", `{"name":"private"}`) + + rec := dispatchABSWithParams(http.MethodGet, "/api/collections/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleGetCollection) + if rec.Code != http.StatusNotFound { + t.Errorf("non-owner private GET status = %d, want 404 (anti-enumeration); body=%s", rec.Code, rec.Body.String()) + } +} + +func TestCollection_Get_NonOwner_Public_OK(t *testing.T) { + hb := newCollectionsHarness(t) + id := createCollectionForUser(t, hb, "1", "", `{"name":"public","isPublic":true}`) + + rec := dispatchABSWithParams(http.MethodGet, "/api/collections/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleGetCollection) + if rec.Code != http.StatusOK { + t.Fatalf("non-owner public GET status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var got map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &got) + if got["name"] != "public" { + t.Errorf("name = %v, want 'public'", got["name"]) + } +} + +func TestCollection_Get_Unknown_404(t *testing.T) { + hb := newCollectionsHarness(t) + rec := dispatchABSWithParams(http.MethodGet, "/api/collections/01HZZZ", map[string]string{"id": "01HZZZ"}, nil, "1", "", hb.H.handleGetCollection) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } +}