Add comix.to and kagane.to support (#13)
Tracks read progress on comix.to and kagane.to alongside asura and demonic, in both the userscript and the backend. Implements `docs/superpowers/plans/2026-08-03-comix-kagane-support.md`. ## Userscript - `comix` adapter — `/title/<id>-<slug>`; only the id prefix is identity (the slug follows the title). No `og:image`, so the cover is matched by `alt`. - `kagane` adapter — reader URLs are uuids with no chapter number, so it comes out of `og:title`; anchor scanning is structurally impossible, replaced by `latestChapterFromApi` against kagane's same-origin JSON API. - `seriesId` threaded through `latestChapterFromAnchors` so comix can scope its scan to its own series and a recommendation strip cannot win the maximum. - `@match` for both hosts, panel chips, v1.6.0. ## Backend - `latestChapterFrom` cases: comix parses the SSR JSON state blob (`latestChapterUrl`, scoped to the series id); kagane parses API JSON (`chapter_no`). - Poller allowlist extended; `Poller.BrowserFetch` with `fetcherFor(site)` routes kagane to a browser fetcher. Nil means kagane is not polled at all — never a fallback to the TLS fetcher, which would only ever retrieve a challenge page. - `BrowserFetcher`: chromedp against a `headless-shell` sidecar. kagane sits behind a Cloudflare JS challenge that no TLS fingerprint clears, and the request is made inside the page rather than by replaying `cf_clearance`. - `BROWSER_WS_URL` wiring, sidecar in both compose files (no `ports:`, dedicated non-external network), Dockerfile on `golang:1.26-alpine` — chromedp requires go 1.26. - Web UI `--comix` / `--kagane` tokens in both colour branches. ## Notes for review - `series_url` is client-supplied and a headless browser is a strong SSRF primitive, so kagane's host is pinned twice: in `fetchableSeriesURL` and again in `kaganeAPIURL`. - Three chained defects found during verification made the browser path dead under Compose (sidecar flag collision, Chrome's Host-header DNS-rebinding check, the wrong chromedp option). Fixed; the compose comments record the wrong configurations too, so they don't get "simplified" back. - `ALLOWED_ORIGINS` now includes both new origins. Without it every write from comix/kagane silently fails CORS preflight, parks in the retry queue, and drops at the cap. ## Verification 221 backend tests, 32 userscript tests, static `CGO_ENABLED=0` build, both compose configs. Two gaps, both real: 1. The userscript on live pages via Violentmonkey needs a human browser profile — not run. Check: comix series page (title/cover, no chapter), comix chapter page (records the number; an *older* chapter must not regress it), comix SPA navigation without reload, kagane series page (og:image cover), kagane reader (number from `og:title`), both chips opening the right sites. 2. The kagane browser path has not completed end-to-end anywhere. Dial/navigate/fetch is confirmed, but Cloudflare 403'd headless-shell's Chrome on every attempt from the dev sandbox, and comix's poll-through-Docker was blocked by that environment's TLS interception. Both environment-dependent rather than branch defects — the first real deploy is the actual verification. Reviewed-on: #13 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #13.
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mangabm/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 newTestServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return newRouter(s, testConfig())
|
||||
}
|
||||
|
||||
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 forces its latest_checked_at.
|
||||
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
|
||||
t.Helper()
|
||||
if _, err := s.Upsert(store.Bookmark{
|
||||
Key: key,
|
||||
Site: "asura",
|
||||
SeriesID: key,
|
||||
SeriesURL: seriesURL,
|
||||
UpdatedAt: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed %q: %v", key, err)
|
||||
}
|
||||
if err := s.MarkLatestChecked(key, checkedAt); err != nil {
|
||||
t.Fatalf("seed mark %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
|
||||
t.Helper()
|
||||
ts, err := s.LatestCheckedAt(key)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 TestLoadConfigWebPassword(t *testing.T) {
|
||||
t.Setenv("API_TOKEN", "token-abc")
|
||||
t.Setenv("WEB_PASSWORD", "hunter2")
|
||||
if got := loadConfig().WebPassword; got != "hunter2" {
|
||||
t.Fatalf("WebPassword = %q, want hunter2", got)
|
||||
}
|
||||
|
||||
t.Setenv("WEB_PASSWORD", "")
|
||||
if got := loadConfig().WebPassword; got != "" {
|
||||
t.Fatalf("WebPassword = %q with the variable unset, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
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 `if cfg.WebPassword != ""`
|
||||
// block in newRouter, so it must keep working on a deployment that never set
|
||||
// WEB_PASSWORD — 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)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "nopass.db")
|
||||
s, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
cfg := testConfig() // WebPassword empty
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user