13e8e73da7
The browser UI signs in with a Discord authorization code grant (identify + guilds.members.read) instead of a shared password. Guild membership is the gate; the owner's Discord ID is the only identity allowed in while registration is closed. Sessions become rows in a sessions table with opaque random ids — the cookie carries only the id, looked up and expiry-checked per request — so deleting a row revokes a session. HMAC cookie signing, its derived key, and WEB_PASSWORD are gone, and no replacement signing secret is introduced (ADR-0002). Discord's API base is configurable (DISCORD_API_BASE); the full flow is tested through the real router against a local stub, including the form-encoded token exchange Discord rejects if sent as JSON.
656 lines
21 KiB
Go
656 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/pgtest"
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
const testToken = "s3cret-token"
|
|
|
|
func testConfig() Config {
|
|
return Config{
|
|
Token: testToken,
|
|
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
|
|
Port: "8080",
|
|
}
|
|
}
|
|
|
|
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: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash")),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("store.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
return s, url
|
|
}
|
|
|
|
func auth(req *http.Request) *http.Request {
|
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
|
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 " + testToken},
|
|
{"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.APIBBase != "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.APIBBase != "https://discord.com/api/v10" {
|
|
t.Fatalf("APIBBase = %q, want the Discord default", got.APIBBase)
|
|
}
|
|
}
|
|
|
|
// 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 "+testToken)
|
|
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.
|
|
func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
|
|
if err := os.WriteFile(path, []byte("console.log(1);\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/"+testToken+"/manga-bookmark.user.js", nil)
|
|
newRouter(s, cfg).ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
// Both scripts are served from the same handler on the same token, outside the
|
|
// web UI's auth — a wrong token 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/"+testToken+"/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)
|
|
}
|
|
}
|