From d9d7a1c00d1cce7913a43a71a2ca8d49bae93363 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 6 Aug 2026 03:05:14 +0700 Subject: [PATCH] feat(api): validate kind on PUT /bookmarks --- backend/internal/api/handlers.go | 9 ++++ backend/main_test.go | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index a1085bd..597cfbb 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -78,6 +78,15 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) { return } + // Same rule as status: empty means "keep the stored value". An unknown + // value is a client bug, not something to silently coerce to manga. + switch b.Kind { + case "", store.KindManga, store.KindNovel: + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid kind"}) + return + } + // Candidate timestamp, not a decision: Upsert keeps the stored one unless // reading progress actually moved. Any client value is ignored. b.UpdatedAt = time.Now().UnixMilli() diff --git a/backend/main_test.go b/backend/main_test.go index 1a0aa11..cbc6d6a 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -243,3 +243,76 @@ func TestGzipCompressesTextNotFonts(t *testing.T) { t.Errorf("Content-Encoding without Accept-Encoding = %q, want empty", enc) } } + +func TestPutKindValidation(t *testing.T) { + cases := []struct { + name string + kind string + want int + }{ + {"empty is no opinion", "", http.StatusOK}, + {"manga", "manga", http.StatusOK}, + {"novel", "novel", http.StatusOK}, + {"garbage", "comic", http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := newTestServer(t) + body := fmt.Sprintf(`{"title":"Solo","kind":%q}`, tc.kind) + req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", + strings.NewReader(body))) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != tc.want { + t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String()) + } + if tc.want != http.StatusOK { + return + } + var got store.Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + want := tc.kind + if want == "" { + want = "manga" + } + if got.Kind != want { + t.Fatalf("stored kind = %q, want %q", got.Kind, want) + } + }) + } +} + +// The preserve path: a novel row re-PUT by a client that omits the field +// entirely must stay a novel and still record the progress it carried. +func TestPutOmittedKindPreservesNovelAndAppliesProgress(t *testing.T) { + srv := newTestServer(t) + const key = "/bookmarks/lightnovelworld:a-will-eternal" + + seed := auth(httptest.NewRequest(http.MethodPut, key, + strings.NewReader(`{"title":"A Will Eternal","kind":"novel","last_chapter_num":10}`))) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, seed) + if rr.Code != http.StatusOK { + t.Fatalf("seed status = %d, want 200 (%s)", rr.Code, rr.Body.String()) + } + + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, key, + strings.NewReader(`{"title":"A Will Eternal","last_chapter_num":11}`)))) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rr.Code, rr.Body.String()) + } + var got store.Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Kind != store.KindNovel { + t.Fatalf("Kind = %q, want novel", got.Kind) + } + if got.LastChapterNum != 11 { + t.Fatalf("LastChapterNum = %v, want 11", got.LastChapterNum) + } +}