b0bf6fe770
Guild membership is now the whole gate: discordCallback checks membership
(and DISCORD_REQUIRED_ROLE when set), then Store.EnsureReader creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before EnsureReader, so nothing is created as a side
effect of being turned away. OWNER_DISCORD_ID keeps seeding the owner, but
only as the administrator — it no longer gates sign-in.
The cutover grace path is gone with it: API_TOKEN, API_TOKEN_GRACE_UNTIL
and the legacy branch in httpmw.ResolveReader are deleted, so a credential
authenticates exactly one Reader or nothing. That also lets
userscript.Handler drop the re-derivation — the resolved path segment is
already the credential to substitute.
New surfaces: an empty library offers both install links instead of
describing a filter (listView.Fresh, which also hides the action key it has
nothing to name), and the owner alone gets a Readers panel with
POST /readers/{id}/revoke (404 for anyone else) to sign a Reader out
everywhere.
Isolation is asserted from both directions rather than by counting one
Reader's rows, and the shared-series invariant is pinned: two Readers on
one series produce one series row, two independent progresses, one poll
per due cycle, and one Reader's delete leaves the other's bookmark and the
poll intact.
675 lines
22 KiB
Go
675 lines
22 KiB
Go
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)
|
|
}
|
|
}
|