package main import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "bookmarkmanager/backend/internal/pgtest" "bookmarkmanager/backend/internal/store" "bookmarkmanager/backend/internal/token" ) // testTokenKey derives every test Reader's credential; it must match the key // newTestStoreURL seeds the owner with, or derived credentials authenticate // nothing. const testTokenKey = "test-token-key" // testDiscordID is the owner row's discord_id (newTestStoreURL); the derived // credential is a function of it. const testDiscordID = "test-owner" func testConfig() Config { return Config{ TokenKey: testTokenKey, AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"}, Port: "8080", } } // ownerCredential is the owner's epoch-0 derived credential: the string the // install links carry and the userscript routes authenticate. func ownerCredential() string { return token.Token([]byte(testTokenKey), testDiscordID, 0) } func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) } func newTestServer(t *testing.T) http.Handler { t.Helper() return newRouter(newTestStore(t), testConfig()) } func newTestStore(t *testing.T) *store.Store { t.Helper() s, _ := newTestStoreURL(t) return s } // newTestStoreURL is newTestStore plus the database URL, for tests that need // to reach the same database directly. func newTestStoreURL(t *testing.T) (*store.Store, string) { t.Helper() url := pgtest.URL(t) s, err := store.Open(url, store.Owner{ DiscordID: testDiscordID, TokenHash: token.Hash(ownerCredential()), }) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { s.Close() }) return s, url } // auth authenticates a request as the owner Reader, whose derived credential // is the only thing the API accepts. func auth(req *http.Request) *http.Request { req.Header.Set("Authorization", "Bearer "+ownerCredential()) return req } func floatPtr(f float64) *float64 { return &f } // seedForCheck inserts a bookmark (and with it its series) and forces the // series' latest_checked_at. func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) { t.Helper() site, seriesID, ok := strings.Cut(key, ":") if !ok { t.Fatalf("key %q: no ':' separator", key) } if _, err := s.Upsert(s.OwnerID(), store.Bookmark{ Key: key, Site: site, SeriesID: seriesID, SeriesURL: seriesURL, UpdatedAt: 1000, }); err != nil { t.Fatalf("seed %q: %v", key, err) } if err := s.MarkLatestChecked(site, seriesID, checkedAt); err != nil { t.Fatalf("seed mark %q: %v", key, err) } } func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 { t.Helper() site, seriesID, ok := strings.Cut(key, ":") if !ok { t.Fatalf("key %q: no ':' separator", key) } ts, err := s.LatestCheckedAt(site, seriesID) if err != nil { t.Fatalf("LatestCheckedAt %q: %v", key, err) } return ts } func TestHealthzNoAuth(t *testing.T) { srv := newTestServer(t) rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if rr.Code != http.StatusOK { t.Fatalf("healthz status = %d, want 200", rr.Code) } if rr.Body.String() != "ok" { t.Fatalf("healthz body = %q, want ok", rr.Body.String()) } } func TestAuthRequired(t *testing.T) { srv := newTestServer(t) cases := []struct { name string header string }{ {"no header", ""}, {"bad token", "Bearer wrong"}, {"not bearer", "Basic " + ownerCredential()}, {"empty bearer", "Bearer "}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil) if tc.header != "" { req.Header.Set("Authorization", tc.header) } rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rr.Code) } }) } } func TestAuthAccepted(t *testing.T) { srv := newTestServer(t) rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if got := rr.Body.String(); got != "[]\n" { t.Fatalf("empty list body = %q, want []", got) } } func TestCORSPreflight(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil) req.Header.Set("Origin", "https://asuracomic.net") req.Header.Set("Access-Control-Request-Method", "PUT") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusNoContent { t.Fatalf("preflight status = %d, want 204", rr.Code) } if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" { t.Fatalf("Allow-Origin = %q, want reflected origin", got) } if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" { t.Fatal("Allow-Methods missing") } if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" { t.Fatal("Allow-Headers missing") } } func TestCORSDisallowedOrigin(t *testing.T) { srv := newTestServer(t) req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil) req.Header.Set("Origin", "https://evil.example") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got) } } func TestBookmarkRoundTrip(t *testing.T) { srv := newTestServer(t) key := "asura:solo-leveling-123" in := store.Bookmark{ Title: "Solo Leveling", SeriesURL: "https://asuracomic.net/series/solo-leveling-123", Cover: "https://asuracomic.net/cover.jpg", LastChapter: "Chapter 10", LastChapterNum: 10, LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10", } body, _ := json.Marshal(in) // PUT rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) if rr.Code != http.StatusOK { t.Fatalf("PUT status = %d, want 200", rr.Code) } var stored store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil { t.Fatalf("decode PUT response: %v", err) } if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" { t.Fatalf("derived fields wrong: %+v", stored) } if stored.UpdatedAt == 0 { t.Fatal("server did not set updated_at") } // GET rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) var list []store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil { t.Fatalf("decode list: %v", err) } if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 { t.Fatalf("GET list wrong: %+v", list) } // PUT again (upsert, progress advance) in.LastChapter, in.LastChapterNum = "Chapter 11", 11 body, _ = json.Marshal(in) rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) if rr.Code != http.StatusOK { t.Fatalf("second PUT status = %d", rr.Code) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) json.Unmarshal(rr.Body.Bytes(), &list) if len(list) != 1 || list[0].LastChapterNum != 11 { t.Fatalf("upsert did not update in place: %+v", list) } // DELETE rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil))) if rr.Code != http.StatusNoContent { t.Fatalf("DELETE status = %d, want 204", rr.Code) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) json.Unmarshal(rr.Body.Bytes(), &list) if len(list) != 0 { t.Fatalf("after delete list = %+v, want empty", list) } } // The wire contract (ADR-0004): GET and PUT speak exactly the flat field set // they always did, with the series-owned fields as siblings of the bookmark // fields, not nested. Asserted as a key set, not by inspection. func TestFlatWireFieldSet(t *testing.T) { srv := newTestServer(t) key := "comix:some-title" in := store.Bookmark{ Key: key, Site: "comix", SeriesID: "some-title", Title: "Some Title", SeriesURL: "https://comix.to/title/some-title", Cover: "https://comix.to/covers/some-title.jpg", LastChapter: "Chapter 7", LastChapterNum: 7, LastChapterURL: "https://comix.to/title/some-title/ch/7", Favorite: true, LatestChapter: "Chapter 8", LatestChapterNum: floatPtr(8), Status: store.StatusArchived, Kind: store.KindManga, } body, _ := json.Marshal(in) wantKeys := map[string]bool{ "key": true, "site": true, "series_id": true, "title": true, "series_url": true, "cover": true, "last_chapter": true, "last_chapter_num": true, "last_chapter_url": true, "favorite": true, "latest_chapter": true, "latest_chapter_num": true, "updated_at": true, "status": true, "kind": true, } checkFlat := func(t *testing.T, payload []byte) map[string]json.RawMessage { t.Helper() var obj map[string]json.RawMessage if err := json.Unmarshal(payload, &obj); err != nil { t.Fatalf("decode: %v", err) } if len(obj) != len(wantKeys) { t.Fatalf("field count = %d, want %d (%s)", len(obj), len(wantKeys), payload) } for k := range obj { if !wantKeys[k] { t.Fatalf("unexpected field %q", k) } } return obj } // PUT rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) if rr.Code != http.StatusOK { t.Fatalf("PUT status = %d, want 200", rr.Code) } checkFlat(t, rr.Body.Bytes()) // Every field round-trips with its value, and updated_at is server-stamped. var stored store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil { t.Fatalf("decode PUT response: %v", err) } latestNum := floatPtr(8) want := store.Bookmark{ Key: key, Site: "comix", SeriesID: "some-title", Title: in.Title, SeriesURL: in.SeriesURL, Cover: in.Cover, LastChapter: in.LastChapter, LastChapterNum: in.LastChapterNum, LastChapterURL: in.LastChapterURL, Favorite: true, LatestChapter: in.LatestChapter, LatestChapterNum: latestNum, Status: store.StatusArchived, Kind: store.KindManga, } if stored.Title != want.Title || stored.SeriesURL != want.SeriesURL || stored.Cover != want.Cover || stored.LastChapter != want.LastChapter || stored.LastChapterNum != want.LastChapterNum || stored.LastChapterURL != want.LastChapterURL || stored.Favorite != want.Favorite || stored.LatestChapter != want.LatestChapter || stored.LatestChapterNum == nil || *stored.LatestChapterNum != *want.LatestChapterNum || stored.Status != want.Status || stored.Kind != want.Kind { t.Fatalf("PUT response = %+v, want %+v", stored, want) } if stored.UpdatedAt == 0 { t.Fatal("updated_at not server-stamped") } // GET reports the same flat shape. list := getBookmarks(t, srv) if len(list) != 1 { t.Fatalf("list = %d items, want 1", len(list)) } body2, _ := json.Marshal(list[0]) checkFlat(t, body2) } // A PUT naming an existing series must ignore client-supplied title, cover and // URL — the security boundary from ADR-0003, where a hostile site's scraped // values could otherwise land on a shared row — while progress still lands. func TestPutExistingSeriesIgnoresClientTitleCoverURL(t *testing.T) { srv := newTestServer(t) key := "asura:solo" first := putBookmark(t, srv, key, store.Bookmark{ Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo", Cover: "https://asurascans.com/covers/solo.jpg", LastChapterNum: 10, }) second := putBookmark(t, srv, key, store.Bookmark{ Title: "Scraped Rename", SeriesURL: "https://evil.example/solo", Cover: "https://evil.example/solo.jpg", LastChapterNum: 11, }) if second.Title != first.Title || second.SeriesURL != first.SeriesURL || second.Cover != first.Cover { t.Fatalf("stored = %+v, want original title/url/cover kept", second) } if second.LastChapterNum != 11 { t.Fatalf("LastChapterNum = %v, want 11 — progress must still land", second.LastChapterNum) } } // putBookmark PUTs b at key and returns the bookmark the server echoes back, // which is the row as actually stored (not the request payload). func putBookmark(t *testing.T, srv http.Handler, key string, b store.Bookmark) store.Bookmark { t.Helper() body, _ := json.Marshal(b) rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) if rr.Code != http.StatusOK { t.Fatalf("PUT %s status = %d, body = %s", key, rr.Code, rr.Body.String()) } var out store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatalf("decode PUT response: %v", err) } return out } func getBookmarks(t *testing.T, srv http.Handler) []store.Bookmark { t.Helper() rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) if rr.Code != http.StatusOK { t.Fatalf("GET status = %d", rr.Code) } var list []store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil { t.Fatalf("decode list: %v", err) } return list } // updated_at drives list ordering, so it must move only on a real progress // advance — never on a favorite toggle or a latest-chapter capture. func TestUpsertConditionalUpdatedAt(t *testing.T) { cases := []struct { name string mutate func(store.Bookmark) store.Bookmark wantBumped bool }{ { name: "unchanged progress", mutate: func(b store.Bookmark) store.Bookmark { return b }, wantBumped: false, }, { name: "changed progress", mutate: func(b store.Bookmark) store.Bookmark { b.LastChapter, b.LastChapterNum = "Chapter 11", 11 return b }, wantBumped: true, }, { name: "favorite only", mutate: func(b store.Bookmark) store.Bookmark { b.Favorite = true return b }, wantBumped: false, }, { name: "latest chapter only", mutate: func(b store.Bookmark) store.Bookmark { b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15) return b }, wantBumped: false, }, { name: "unrelated metadata only", mutate: func(b store.Bookmark) store.Bookmark { b.Title, b.Cover = "Renamed", "https://example.test/new.jpg" return b }, wantBumped: false, }, } for i, tc := range cases { t.Run(tc.name, func(t *testing.T) { srv := newTestServer(t) key := fmt.Sprintf("asura:cond-%d", i) first := putBookmark(t, srv, key, store.Bookmark{ Title: "Test", LastChapter: "Chapter 10", LastChapterNum: 10, }) if first.UpdatedAt == 0 { t.Fatal("new bookmark did not get updated_at set") } // Guarantee a later wall-clock ms so a real bump is observable. time.Sleep(2 * time.Millisecond) second := putBookmark(t, srv, key, tc.mutate(first)) if tc.wantBumped && second.UpdatedAt <= first.UpdatedAt { t.Fatalf("updated_at = %d, want > %d", second.UpdatedAt, first.UpdatedAt) } if !tc.wantBumped && second.UpdatedAt != first.UpdatedAt { t.Fatalf("updated_at = %d, want preserved %d", second.UpdatedAt, first.UpdatedAt) } // The PUT response must match what a subsequent GET reports. list := getBookmarks(t, srv) if len(list) != 1 { t.Fatalf("list = %+v, want 1 item", list) } if list[0].UpdatedAt != second.UpdatedAt { t.Fatalf("GET updated_at = %d, PUT echoed %d", list[0].UpdatedAt, second.UpdatedAt) } }) } } func TestFavoriteRoundTrip(t *testing.T) { srv := newTestServer(t) key := "demonic:some-series" stored := putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: true}) if !stored.Favorite { t.Fatalf("PUT response favorite = false, want true") } list := getBookmarks(t, srv) if len(list) != 1 || !list[0].Favorite { t.Fatalf("favorite did not round-trip: %+v", list) } // Unfavoriting must persist too (guards against a write that only ever ORs in true). stored = putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: false}) if stored.Favorite { t.Fatal("PUT response favorite = true after unfavorite") } list = getBookmarks(t, srv) if len(list) != 1 || list[0].Favorite { t.Fatalf("unfavorite did not round-trip: %+v", list) } } func TestLatestChapterNullable(t *testing.T) { srv := newTestServer(t) key := "asura:latest-test" // Never captured: latest_chapter_num must serialize as JSON null. body, _ := json.Marshal(store.Bookmark{Title: "No latest yet"}) rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) if rr.Code != http.StatusOK { t.Fatalf("PUT status = %d", rr.Code) } if !strings.Contains(rr.Body.String(), `"latest_chapter_num":null`) { t.Fatalf("want latest_chapter_num null in response, got %s", rr.Body.String()) } list := getBookmarks(t, srv) if len(list) != 1 || list[0].LatestChapterNum != nil { t.Fatalf("latest_chapter_num = %v, want nil", list[0].LatestChapterNum) } // Once captured it round-trips as a value. stored := putBookmark(t, srv, key, store.Bookmark{ Title: "No latest yet", LatestChapter: "Chapter 162", LatestChapterNum: floatPtr(162), }) if stored.LatestChapterNum == nil || *stored.LatestChapterNum != 162 { t.Fatalf("PUT response latest_chapter_num = %v, want 162", stored.LatestChapterNum) } list = getBookmarks(t, srv) if len(list) != 1 || list[0].LatestChapterNum == nil || *list[0].LatestChapterNum != 162 { t.Fatalf("latest chapter did not round-trip: %+v", list) } if list[0].LatestChapter != "Chapter 162" { t.Fatalf("latest_chapter = %q, want %q", list[0].LatestChapter, "Chapter 162") } } func TestLoadConfigDiscord(t *testing.T) { t.Setenv("DISCORD_CLIENT_ID", "client-1") t.Setenv("DISCORD_CLIENT_SECRET", "client-secret-1") t.Setenv("DISCORD_GUILD_ID", "guild-1") t.Setenv("DISCORD_REQUIRED_ROLE", "role-9") t.Setenv("DISCORD_REDIRECT_URI", "https://bm.example.com/auth/discord/callback") t.Setenv("DISCORD_API_BASE", "https://stub.example/api") if got := loadConfig().Discord; got.ClientID != "client-1" || got.ClientSecret != "client-secret-1" || got.GuildID != "guild-1" || got.RequiredRole != "role-9" || got.RedirectURI != "https://bm.example.com/auth/discord/callback" || got.APIBase != "https://stub.example/api" { t.Fatalf("Discord config = %+v, want every field set", got) } // API base falls back to the Discord default; the role is optional. t.Setenv("DISCORD_REQUIRED_ROLE", "") t.Setenv("DISCORD_API_BASE", "") got := loadConfig().Discord if got.RequiredRole != "" { t.Fatalf("RequiredRole = %q, want empty by default", got.RequiredRole) } if got.APIBase != "https://discord.com/api/v10" { t.Fatalf("APIBase = %q, want the Discord default", got.APIBase) } } // A userscript PUT body has no latest_checked_at field. If the column is ever // moved into bookmarkColumns, this test catches it: the PUT would reset the // cooldown and the poller would re-fetch that series on every single tick. func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) { s := newTestStore(t) srv := newRouter(s, testConfig()) seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777) // Exactly what the userscript sends: no latest_checked_at key at all. body := `{"key":"asura:x","site":"asura","series_id":"x", "series_url":"https://asurascans.com/comics/x", "last_chapter":"Chapter 5","last_chapter_num":5}` req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+ownerCredential()) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String()) } if got := readLatestCheckedAt(t, s, "asura:x"); got != 777 { t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got) } } // The userscript route is registered outside the web UI's Discord auth, so it // must keep working whatever the web config — see internal/userscript for the // handler's own behaviour. The credential in the path is the owner's derived // one, and the served script carries it substituted in. func TestUserscriptServedWithWebUIDisabled(t *testing.T) { path := filepath.Join(t.TempDir(), "manga-bookmark.user.js") if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil { t.Fatalf("write script: %v", err) } s := newTestStore(t) cfg := testConfig() // no Discord config needed for the userscript route cfg.UserscriptPath = path rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/manga-bookmark.user.js", nil) newRouter(s, cfg).ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+ownerCredential()+`"`) { t.Fatalf("served script does not carry the requesting Reader's credential:\n%s", got) } } // Both scripts are served from the same handler, outside the web UI's auth — // a wrong credential is a 404, never a 401. func TestNovelUserscriptServed(t *testing.T) { dir := t.TempDir() novelPath := filepath.Join(dir, "novel-bookmark.user.js") if err := os.WriteFile(novelPath, []byte("// novel\n"), 0o644); err != nil { t.Fatalf("write script: %v", err) } s := newTestStore(t) cfg := testConfig() cfg.NovelUserscriptPath = novelPath srv := newRouter(s, cfg) rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/novel-bookmark.user.js", nil)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { t.Fatalf("Content-Type = %q, want text/javascript", ct) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/wrong-token/novel-bookmark.user.js", nil)) if rr.Code != http.StatusNotFound { t.Fatalf("wrong token status = %d, want 404", rr.Code) } }