feat(audiobooks): GET /collections/{id} — owner and public access
Owner sees their own collection in full-shape (with books[]). Non-owner sees it only when isPublic=true; otherwise 404 with the same body as a genuine not-found (anti-enumeration pattern from the bookmarks sub-project). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d9ea7be3fc
commit
aedf545481
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user