2472 lines
90 KiB
Go
2472 lines
90 KiB
Go
package main
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"bookmarkmanager/backend/internal/latest"
|
||
"bookmarkmanager/backend/internal/session"
|
||
"bookmarkmanager/backend/internal/store"
|
||
"bookmarkmanager/backend/internal/web"
|
||
)
|
||
|
||
// testOwnerID is the Discord identity the stub reports for a sign-in. It is
|
||
// deliberately not the seeded owner's (testDiscordID): registration is open,
|
||
// so the default sign-in is a second Reader registering.
|
||
const testOwnerID = "owner-snowflake"
|
||
|
||
// newWebTestServer returns the full router plus the store behind it, so tests
|
||
// can seed rows and assert on what the handlers wrote back. An optional lane
|
||
// reporter stands in for the running poller; omitted means none is running,
|
||
// which is what every test that is not about the admin page wants.
|
||
func newWebTestServer(t *testing.T, cfg Config, lanes ...web.LaneReporter) (http.Handler, *store.Store) {
|
||
t.Helper()
|
||
st := newTestStore(t)
|
||
var reporter web.LaneReporter
|
||
if len(lanes) > 0 {
|
||
reporter = lanes[0]
|
||
}
|
||
return newRouter(st, cfg, reporter), st
|
||
}
|
||
|
||
// sessionCookie mints a live session row for the owner and returns the cookie
|
||
// carrying its id — the only credential the UI accepts.
|
||
func sessionCookie(t *testing.T, st *store.Store) *http.Cookie {
|
||
t.Helper()
|
||
sess, err := st.CreateSession(session.NewID(), st.OwnerID(), session.SessionTTL)
|
||
if err != nil {
|
||
t.Fatalf("CreateSession: %v", err)
|
||
}
|
||
return &http.Cookie{Name: session.CookieName, Value: sess.ID}
|
||
}
|
||
|
||
// discordStub is a minimal Discord API. The router is pointed at it through
|
||
// the configured API base URL, so the real request construction — including
|
||
// the form-encoded token exchange — is what the tests exercise, not an
|
||
// injected client interface.
|
||
type discordStub struct {
|
||
ownerID string // id /users/@me answers
|
||
member bool // whether the member endpoint reports membership
|
||
roles []string // roles the member holds
|
||
tokenStatus int // status the token endpoint answers; 0 = 200
|
||
userStatus int // status users/@me answers; 0 = 200
|
||
memberStatus int // status the member endpoint answers; 0 = member ? 200 : 404
|
||
|
||
tokenRequests []tokenRequest // recorded token exchanges
|
||
userAuth []string // Authorization headers seen on users/@me
|
||
memberAuth []string // Authorization headers seen on the member endpoint
|
||
memberPaths []string
|
||
}
|
||
|
||
type tokenRequest struct {
|
||
contentType string
|
||
form url.Values
|
||
}
|
||
|
||
func newDiscordStub(t *testing.T) (*discordStub, *httptest.Server) {
|
||
t.Helper()
|
||
st := &discordStub{ownerID: testOwnerID, member: true}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch {
|
||
case r.URL.Path == "/oauth2/token":
|
||
body, _ := io.ReadAll(r.Body)
|
||
form, _ := url.ParseQuery(string(body))
|
||
st.tokenRequests = append(st.tokenRequests, tokenRequest{
|
||
contentType: r.Header.Get("Content-Type"),
|
||
form: form,
|
||
})
|
||
status := st.tokenStatus
|
||
if status == 0 {
|
||
status = http.StatusOK
|
||
}
|
||
w.WriteHeader(status)
|
||
if status == http.StatusOK {
|
||
fmt.Fprintf(w, `{"access_token":"tok-%d","token_type":"Bearer"}`, len(st.tokenRequests))
|
||
}
|
||
case r.URL.Path == "/users/@me":
|
||
st.userAuth = append(st.userAuth, r.Header.Get("Authorization"))
|
||
status := st.userStatus
|
||
if status == 0 {
|
||
status = http.StatusOK
|
||
}
|
||
w.WriteHeader(status)
|
||
if status == http.StatusOK {
|
||
fmt.Fprintf(w, `{"id":%q,"username":"owner"}`, st.ownerID)
|
||
}
|
||
// Discord answers the bot endpoint with 401 for a user Bearer token.
|
||
// Standing in for that keeps a regression onto it loud: without this
|
||
// the request would fall through to 404 and read as "not a member",
|
||
// which is a refusal the caller treats as ordinary.
|
||
case strings.HasPrefix(r.URL.Path, "/guilds/"):
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
case strings.HasPrefix(r.URL.Path, "/users/@me/guilds/"):
|
||
st.memberPaths = append(st.memberPaths, r.URL.Path)
|
||
st.memberAuth = append(st.memberAuth, r.Header.Get("Authorization"))
|
||
status := st.memberStatus
|
||
if status == 0 {
|
||
if st.member {
|
||
status = http.StatusOK
|
||
} else {
|
||
status = http.StatusNotFound
|
||
}
|
||
}
|
||
w.WriteHeader(status)
|
||
if status == http.StatusOK {
|
||
roles, _ := json.Marshal(st.roles)
|
||
fmt.Fprintf(w, `{"roles":%s}`, roles)
|
||
}
|
||
default:
|
||
http.NotFound(w, r)
|
||
}
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
return st, srv
|
||
}
|
||
|
||
// discordConfig is the OAuth application config every sign-in test uses, with
|
||
// the API base pointed at a stub.
|
||
func discordConfig(stubURL string) web.DiscordConfig {
|
||
return web.DiscordConfig{
|
||
ClientID: "client-1",
|
||
ClientSecret: "client-secret-1",
|
||
GuildID: "guild-1",
|
||
APIBase: stubURL,
|
||
RedirectURI: "https://bm.example.com/auth/discord/callback",
|
||
}
|
||
}
|
||
|
||
// oauthWebTestServer returns the full router, its store, and a Discord stub
|
||
// wired as the configured API — the starting point for sign-in tests.
|
||
func oauthWebTestServer(t *testing.T, lanes ...web.LaneReporter) (http.Handler, *store.Store, *discordStub) {
|
||
t.Helper()
|
||
stub, srv := newDiscordStub(t)
|
||
cfg := testConfig()
|
||
cfg.Discord = discordConfig(srv.URL)
|
||
router, st := newWebTestServer(t, cfg, lanes...)
|
||
return router, st, stub
|
||
}
|
||
|
||
// startSignIn runs GET /auth/discord and returns the state Discord would echo
|
||
// back. A failed start fails the test.
|
||
func startSignIn(t *testing.T, srv http.Handler) string {
|
||
t.Helper()
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/auth/discord", nil))
|
||
if rr.Code != http.StatusSeeOther {
|
||
t.Fatalf("GET /auth/discord status = %d, want 303", rr.Code)
|
||
}
|
||
loc, err := url.Parse(rr.Header().Get("Location"))
|
||
if err != nil {
|
||
t.Fatalf("Location %q: %v", rr.Header().Get("Location"), err)
|
||
}
|
||
if loc.Path != "/oauth2/authorize" {
|
||
t.Fatalf("redirect path = %q, want /oauth2/authorize", loc.Path)
|
||
}
|
||
if state := loc.Query().Get("state"); state != "" {
|
||
return state
|
||
}
|
||
t.Fatal("authorize URL carries no state")
|
||
return ""
|
||
}
|
||
|
||
// completeSignIn drives the callback with a fresh code for state.
|
||
func completeSignIn(t *testing.T, srv http.Handler, state string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
req := httptest.NewRequest(http.MethodGet,
|
||
"/auth/discord/callback?code=discord-code-1&state="+url.QueryEscape(state), nil)
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
return rr
|
||
}
|
||
|
||
// storeReaders is the roster, ordered oldest first — the owner heads it.
|
||
func storeReaders(t *testing.T, st *store.Store) []store.ReaderSummary {
|
||
t.Helper()
|
||
readers, err := st.Readers()
|
||
if err != nil {
|
||
t.Fatalf("Readers: %v", err)
|
||
}
|
||
return readers
|
||
}
|
||
|
||
// signInCookie runs a whole Discord sign-in and returns the session cookie it
|
||
// minted, for the Reader the stub reports (testOwnerID).
|
||
func signInCookie(t *testing.T, srv http.Handler) *http.Cookie {
|
||
t.Helper()
|
||
rr := completeSignIn(t, srv, startSignIn(t, srv))
|
||
cookies := rr.Result().Cookies()
|
||
if rr.Code != http.StatusSeeOther || len(cookies) != 1 {
|
||
t.Fatalf("sign-in status = %d with %d cookies, want 303 and one", rr.Code, len(cookies))
|
||
}
|
||
return cookies[0]
|
||
}
|
||
|
||
// signedInReader is signInCookie plus the Reader the session names.
|
||
func signedInReader(t *testing.T, srv http.Handler, st *store.Store) int64 {
|
||
t.Helper()
|
||
sess, ok, err := st.GetSession(signInCookie(t, srv).Value, time.Now())
|
||
if err != nil || !ok {
|
||
t.Fatalf("session lookup: ok=%v err=%v", ok, err)
|
||
}
|
||
return sess.ReaderID
|
||
}
|
||
|
||
func TestIndexWithoutSessionShowsLogin(t *testing.T) {
|
||
srv, _ := newWebTestServer(t, testConfig())
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
|
||
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("GET / status = %d, want 200", rr.Code)
|
||
}
|
||
if !strings.Contains(rr.Body.String(), "Continue with Discord") {
|
||
t.Fatal("GET / without a session did not render the Discord sign-in button")
|
||
}
|
||
}
|
||
|
||
func TestIndexWithSessionShowsList(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
if _, err := st.Upsert(st.OwnerID(), store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||
UpdatedAt: time.Now().UnixMilli(),
|
||
}); err != nil {
|
||
t.Fatalf("Upsert: %v", err)
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("GET / status = %d, want 200", rr.Code)
|
||
}
|
||
if !strings.Contains(rr.Body.String(), "Solo Leveling") {
|
||
t.Fatal("GET / with a session did not render the bookmark title")
|
||
}
|
||
}
|
||
|
||
func TestDiscordLoginFullFlow(t *testing.T) {
|
||
srv, st, stub := oauthWebTestServer(t)
|
||
|
||
// The authorize redirect carries the app, the scopes the gate needs, and
|
||
// a fresh state.
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/auth/discord", nil))
|
||
if rr.Code != http.StatusSeeOther {
|
||
t.Fatalf("GET /auth/discord status = %d, want 303", rr.Code)
|
||
}
|
||
loc, err := url.Parse(rr.Header().Get("Location"))
|
||
if err != nil {
|
||
t.Fatalf("Location: %v", err)
|
||
}
|
||
q := loc.Query()
|
||
if q.Get("client_id") != "client-1" || q.Get("response_type") != "code" {
|
||
t.Fatalf("authorize query = %v, want client_id client-1 and response_type code", q)
|
||
}
|
||
if q.Get("redirect_uri") != "https://bm.example.com/auth/discord/callback" {
|
||
t.Fatalf("redirect_uri = %q, want the configured callback", q.Get("redirect_uri"))
|
||
}
|
||
for _, want := range []string{"identify", "guilds.members.read"} {
|
||
if !strings.Contains(q.Get("scope"), want) {
|
||
t.Fatalf("scope %q missing %s", q.Get("scope"), want)
|
||
}
|
||
}
|
||
state := q.Get("state")
|
||
if state == "" {
|
||
t.Fatal("authorize URL carries no state")
|
||
}
|
||
|
||
// The callback lands the reader logged in.
|
||
rr = completeSignIn(t, srv, state)
|
||
if rr.Code != http.StatusSeeOther {
|
||
t.Fatalf("callback status = %d, want 303 (body %s)", rr.Code, rr.Body.String())
|
||
}
|
||
cookies := rr.Result().Cookies()
|
||
if len(cookies) != 1 || cookies[0].Name != session.CookieName || cookies[0].Value == "" {
|
||
t.Fatalf("callback cookies = %+v, want one non-empty %s", cookies, session.CookieName)
|
||
}
|
||
|
||
// The token exchange went out form-encoded — the wire format Discord
|
||
// rejects if JSON — with every field Discord requires.
|
||
if len(stub.tokenRequests) != 1 {
|
||
t.Fatalf("token exchanges = %d, want 1", len(stub.tokenRequests))
|
||
}
|
||
tr := stub.tokenRequests[0]
|
||
if !strings.HasPrefix(tr.contentType, "application/x-www-form-urlencoded") {
|
||
t.Fatalf("token exchange Content-Type = %q, want form-urlencoded", tr.contentType)
|
||
}
|
||
wantForm := url.Values{
|
||
"client_id": {"client-1"},
|
||
"client_secret": {"client-secret-1"},
|
||
"grant_type": {"authorization_code"},
|
||
"code": {"discord-code-1"},
|
||
"redirect_uri": {"https://bm.example.com/auth/discord/callback"},
|
||
}
|
||
if !reflect.DeepEqual(tr.form, wantForm) {
|
||
t.Fatalf("token form = %v, want %v", tr.form, wantForm)
|
||
}
|
||
|
||
// Identity and membership were fetched with the exchanged token, and the
|
||
// membership check used the OAuth single-guild endpoint — the one
|
||
// guilds.members.read grants, not its bot-token twin.
|
||
if len(stub.userAuth) != 1 || stub.userAuth[0] != "Bearer tok-1" {
|
||
t.Fatalf("users/@me Authorization = %v, want [Bearer tok-1]", stub.userAuth)
|
||
}
|
||
if len(stub.memberPaths) != 1 || stub.memberPaths[0] != "/users/@me/guilds/guild-1/member" {
|
||
t.Fatalf("member requests = %v, want the OAuth single-guild endpoint", stub.memberPaths)
|
||
}
|
||
if len(stub.memberAuth) != 1 || stub.memberAuth[0] != "Bearer tok-1" {
|
||
t.Fatalf("member Authorization = %v, want [Bearer tok-1]", stub.memberAuth)
|
||
}
|
||
|
||
// The session row exists, and the cookie it minted opens the library.
|
||
if _, ok, err := st.GetSession(cookies[0].Value, time.Now()); err != nil || !ok {
|
||
t.Fatalf("session row: ok=%v err=%v, want ok", ok, err)
|
||
}
|
||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
req.AddCookie(cookies[0])
|
||
rr = httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK || strings.Contains(rr.Body.String(), "Continue with Discord") {
|
||
t.Fatalf("GET / with the new cookie = %d, still showing the login page", rr.Code)
|
||
}
|
||
}
|
||
|
||
func TestDiscordCallbackRejectsMissingState(t *testing.T) {
|
||
srv, _, stub := oauthWebTestServer(t)
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
||
"/auth/discord/callback?code=discord-code-1", nil))
|
||
if rr.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400", rr.Code)
|
||
}
|
||
if len(rr.Result().Cookies()) != 0 {
|
||
t.Fatal("a refused callback set a cookie")
|
||
}
|
||
if len(stub.tokenRequests) != 0 || len(stub.userAuth) != 0 {
|
||
t.Fatal("a state-less callback still called Discord")
|
||
}
|
||
}
|
||
|
||
func TestDiscordCallbackRejectsMismatchedState(t *testing.T) {
|
||
srv, _, stub := oauthWebTestServer(t)
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
||
"/auth/discord/callback?code=discord-code-1&state=not-the-state", nil))
|
||
if rr.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400", rr.Code)
|
||
}
|
||
if len(rr.Result().Cookies()) != 0 {
|
||
t.Fatal("a refused callback set a cookie")
|
||
}
|
||
if len(stub.tokenRequests) != 0 || len(stub.userAuth) != 0 {
|
||
t.Fatal("a mismatched-state callback still called Discord")
|
||
}
|
||
}
|
||
|
||
// A state is single-use: replaying a consumed callback is refused.
|
||
func TestDiscordCallbackStateIsSingleUse(t *testing.T) {
|
||
srv, _, _ := oauthWebTestServer(t)
|
||
state := startSignIn(t, srv)
|
||
if rr := completeSignIn(t, srv, state); rr.Code != http.StatusSeeOther {
|
||
t.Fatalf("first use status = %d, want 303", rr.Code)
|
||
}
|
||
rr := completeSignIn(t, srv, state)
|
||
if rr.Code != http.StatusBadRequest {
|
||
t.Fatalf("replayed state status = %d, want 400", rr.Code)
|
||
}
|
||
}
|
||
|
||
// TestDiscordLoginRefusesNonMember covers the refusals that must read the
|
||
// same: no membership, membership without the required role, and a member
|
||
// endpoint that answers 403 (token lacking the scope). Neither may leak the
|
||
// guild's existence or id, and neither may create anything.
|
||
func TestDiscordLoginRefusesNonMember(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
member bool
|
||
memberStatus int
|
||
roles []string
|
||
require string
|
||
}{
|
||
{"not a member", false, 0, nil, ""},
|
||
{"missing the required role", true, 0, []string{"role-1"}, "role-2"},
|
||
{"member endpoint 403", true, http.StatusForbidden, nil, ""},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
stub, srv := newDiscordStub(t)
|
||
stub.member = tc.member
|
||
stub.memberStatus = tc.memberStatus
|
||
stub.roles = tc.roles
|
||
cfg := testConfig()
|
||
cfg.Discord = discordConfig(srv.URL)
|
||
cfg.Discord.RequiredRole = tc.require
|
||
st := newTestStore(t)
|
||
router := newRouter(st, cfg, nil)
|
||
|
||
rr := completeSignIn(t, router, startSignIn(t, router))
|
||
if rr.Code != http.StatusForbidden {
|
||
t.Fatalf("status = %d, want 403", rr.Code)
|
||
}
|
||
if !strings.Contains(rr.Body.String(), "not a member of this community") {
|
||
t.Fatalf("refusal body = %q, want the clear non-member explanation", rr.Body.String())
|
||
}
|
||
if strings.Contains(rr.Body.String(), "guild-1") {
|
||
t.Fatalf("refusal body = %q, leaks the guild id", rr.Body.String())
|
||
}
|
||
if len(rr.Result().Cookies()) != 0 {
|
||
t.Fatal("a refused sign-in set a cookie")
|
||
}
|
||
// The seed owner is still the only Reader, and no session exists.
|
||
if n := len(storeReaders(t, st)); n != 1 {
|
||
t.Fatalf("readers = %d after a refusal, want 1", n)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// The positive role-gated path: a member holding the required role signs in.
|
||
func TestDiscordLoginRequiresRolePositive(t *testing.T) {
|
||
stub, srv := newDiscordStub(t)
|
||
stub.roles = []string{"role-1"}
|
||
cfg := testConfig()
|
||
cfg.Discord = discordConfig(srv.URL)
|
||
cfg.Discord.RequiredRole = "role-1"
|
||
router, st := newWebTestServer(t, cfg)
|
||
|
||
rr := completeSignIn(t, router, startSignIn(t, router))
|
||
if rr.Code != http.StatusSeeOther {
|
||
t.Fatalf("status = %d, want 303 (body %s)", rr.Code, rr.Body.String())
|
||
}
|
||
cookies := rr.Result().Cookies()
|
||
if len(cookies) != 1 || cookies[0].Value == "" {
|
||
t.Fatalf("cookies = %+v, want a session cookie", cookies)
|
||
}
|
||
if _, ok, err := st.GetSession(cookies[0].Value, time.Now()); err != nil || !ok {
|
||
t.Fatalf("session row: ok=%v err=%v, want ok", ok, err)
|
||
}
|
||
}
|
||
|
||
// Registration is the login: a guild member who is not the owner gets their
|
||
// own Reader on first sight, and every later sign-in reuses it rather than
|
||
// minting a second library.
|
||
func TestGuildMemberRegistersOnFirstLoginAndReusesIt(t *testing.T) {
|
||
router, st, _ := oauthWebTestServer(t)
|
||
if n := len(storeReaders(t, st)); n != 1 {
|
||
t.Fatalf("readers before any login = %d, want just the seeded owner", n)
|
||
}
|
||
|
||
first := signedInReader(t, router, st)
|
||
if first == st.OwnerID() {
|
||
t.Fatal("a non-owner member's session landed on the owner Reader")
|
||
}
|
||
readers := storeReaders(t, st)
|
||
if len(readers) != 2 {
|
||
t.Fatalf("readers after first login = %d, want 2", len(readers))
|
||
}
|
||
if readers[1].DiscordID != testOwnerID {
|
||
t.Fatalf("registered Reader's discord id = %q, want %q", readers[1].DiscordID, testOwnerID)
|
||
}
|
||
|
||
second := signedInReader(t, router, st)
|
||
if second != first {
|
||
t.Fatalf("second login landed on Reader %d, want the existing %d", second, first)
|
||
}
|
||
if n := len(storeReaders(t, st)); n != 2 {
|
||
t.Fatalf("readers after second login = %d, want 2 (no duplicate)", n)
|
||
}
|
||
}
|
||
|
||
// The seeded owner signs in through the same path: their row is found, not
|
||
// created a second time.
|
||
func TestOwnerLoginReusesTheSeededReader(t *testing.T) {
|
||
stub, stubSrv := newDiscordStub(t)
|
||
stub.ownerID = testDiscordID
|
||
cfg := testConfig()
|
||
cfg.Discord = discordConfig(stubSrv.URL)
|
||
router, st := newWebTestServer(t, cfg)
|
||
|
||
if got := signedInReader(t, router, st); got != st.OwnerID() {
|
||
t.Fatalf("owner's sign-in landed on Reader %d, want the seeded %d", got, st.OwnerID())
|
||
}
|
||
if n := len(storeReaders(t, st)); n != 1 {
|
||
t.Fatalf("readers after the owner's login = %d, want 1 (the seed was duplicated)", n)
|
||
}
|
||
}
|
||
|
||
// A brand-new Reader's page explains how a library gets filled and offers both
|
||
// install links, and the script it serves carries their credential — not the
|
||
// owner's.
|
||
func TestNewReaderSeesEmptyLibraryAndTheirOwnScript(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)
|
||
}
|
||
_, stubSrv := newDiscordStub(t)
|
||
cfg := testConfig()
|
||
cfg.Discord = discordConfig(stubSrv.URL)
|
||
cfg.UserscriptPath = path
|
||
router, st := newWebTestServer(t, cfg)
|
||
|
||
cookie := signInCookie(t, router)
|
||
reader, _, err := st.GetSession(cookie.Value, time.Now())
|
||
if err != nil {
|
||
t.Fatalf("GetSession: %v", err)
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
req.AddCookie(cookie)
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("GET / status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
for _, want := range []string{
|
||
"Nothing here yet",
|
||
`href="/install/manga-bookmark.user.js"`,
|
||
`href="/install/novel-bookmark.user.js"`,
|
||
} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("empty library page lacks %q", want)
|
||
}
|
||
}
|
||
// The Readers panel is the owner's alone.
|
||
if strings.Contains(body, `id="readers"`) {
|
||
t.Error("a non-owner Reader was shown the Readers panel")
|
||
}
|
||
|
||
theirCred := readerCredential(testOwnerID)
|
||
if theirCred == ownerCredential() {
|
||
t.Fatal("test setup: the new Reader's credential collides with the owner's")
|
||
}
|
||
req = httptest.NewRequest(http.MethodGet, "/install/manga-bookmark.user.js", nil)
|
||
req.AddCookie(cookie)
|
||
rr = httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+theirCred+`"`) {
|
||
t.Fatalf("new Reader's script does not carry their own credential:\n%s", got)
|
||
}
|
||
if strings.Contains(rr.Body.String(), ownerCredential()) {
|
||
t.Fatal("new Reader's script carries the owner's credential")
|
||
}
|
||
if reader.ReaderID == st.OwnerID() {
|
||
t.Fatal("the new Reader's session points at the owner")
|
||
}
|
||
}
|
||
|
||
// Only the owner may revoke, and a revocation kills every session that Reader
|
||
// holds while leaving everyone else signed in.
|
||
func TestOwnerRevokesAnotherReadersSessions(t *testing.T) {
|
||
router, st, _ := oauthWebTestServer(t)
|
||
theirCookie := signInCookie(t, router)
|
||
theirSession, _, err := st.GetSession(theirCookie.Value, time.Now())
|
||
if err != nil {
|
||
t.Fatalf("GetSession: %v", err)
|
||
}
|
||
ownerCookie := sessionCookie(t, st)
|
||
|
||
// A non-owner cannot reach the endpoint at all: for them it does not exist.
|
||
req := httptest.NewRequest(http.MethodPost,
|
||
"/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/revoke", nil)
|
||
req.AddCookie(theirCookie)
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusNotFound {
|
||
t.Fatalf("non-owner revoke: status = %d, want 404", rr.Code)
|
||
}
|
||
if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok {
|
||
t.Fatal("a non-owner's revoke attempt still killed the owner's session")
|
||
}
|
||
|
||
// The owner is not a revocable Reader: the button would sign out the browser
|
||
// making the request, so both the roster and the endpoint refuse it.
|
||
req = httptest.NewRequest(http.MethodPost,
|
||
"/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/revoke", nil)
|
||
req.AddCookie(ownerCookie)
|
||
rr = httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusNotFound {
|
||
t.Fatalf("owner revoking themselves: status = %d, want 404", rr.Code)
|
||
}
|
||
if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok {
|
||
t.Fatal("the owner signed themselves out through the revoke endpoint")
|
||
}
|
||
|
||
req = httptest.NewRequest(http.MethodPost,
|
||
"/readers/"+strconv.FormatInt(theirSession.ReaderID, 10)+"/revoke", nil)
|
||
req.AddCookie(ownerCookie)
|
||
rr = httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("owner revoke: status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||
}
|
||
if !strings.Contains(rr.Body.String(), `id="readers"`) {
|
||
t.Fatalf("revoke response did not re-render the roster:\n%s", rr.Body.String())
|
||
}
|
||
|
||
// The revoked Reader's next request is rejected; the owner is untouched.
|
||
req = httptest.NewRequest(http.MethodGet, "/ui/list", nil)
|
||
req.AddCookie(theirCookie)
|
||
rr = httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusUnauthorized {
|
||
t.Fatalf("revoked session: status = %d, want 401", rr.Code)
|
||
}
|
||
if _, ok, _ := st.GetSession(ownerCookie.Value, time.Now()); !ok {
|
||
t.Fatal("revoking another Reader took the owner's session with it")
|
||
}
|
||
}
|
||
|
||
// fakeLanes is the admin page's poller stand-in: one fixed snapshot, so the
|
||
// page's tests need neither a poller nor a Site.
|
||
type fakeLanes struct{ status latest.Status }
|
||
|
||
func (f fakeLanes) LaneStatus() latest.Status { return f.status }
|
||
|
||
// Every admin address carries the same navigation, while the roster only lives
|
||
// on its own page and the other pages keep their shells independent.
|
||
func TestAdminPagesCarrySharedNavigation(t *testing.T) {
|
||
router, st, _ := oauthWebTestServer(t)
|
||
theirCookie := signInCookie(t, router)
|
||
ownerCookie := sessionCookie(t, st)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
req.AddCookie(ownerCookie)
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
body := rr.Body.String()
|
||
if strings.Contains(body, `id="readers"`) {
|
||
t.Error("the reading page still carries the roster; it belongs on /admin/readers")
|
||
}
|
||
if !strings.Contains(body, `href="/admin"`) {
|
||
t.Error("the owner's reading page offers no link to the admin page")
|
||
}
|
||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||
req.AddCookie(theirCookie)
|
||
rr = httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if strings.Contains(rr.Body.String(), `href="/admin"`) {
|
||
t.Error("a non-owner was offered the admin link")
|
||
}
|
||
|
||
for _, page := range []struct {
|
||
path string
|
||
name string
|
||
}{
|
||
{"/admin", "Overview"},
|
||
{"/admin/lanes", "Lanes"},
|
||
{"/admin/readers", "Readers"},
|
||
{"/admin/series", "Series"},
|
||
} {
|
||
t.Run(page.name, func(t *testing.T) {
|
||
req := httptest.NewRequest(http.MethodGet, page.path, nil)
|
||
req.AddCookie(ownerCookie)
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("GET %s status = %d, want 200", page.path, rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
if !strings.Contains(body, `class="topbar-actions"`) {
|
||
t.Errorf("%s has no topbar action cluster:\n%s", page.path, body)
|
||
}
|
||
if !strings.Contains(body, `aria-label="Admin pages"`) {
|
||
t.Errorf("%s has no admin navigation:\n%s", page.path, body)
|
||
}
|
||
if strings.Count(body, `aria-current="page"`) != 1 {
|
||
t.Errorf("%s has %d active admin tabs, want 1:\n%s", page.path, strings.Count(body, `aria-current="page"`), body)
|
||
}
|
||
if !strings.Contains(body, page.name) {
|
||
t.Errorf("%s does not name its active page %q:\n%s", page.path, page.name, body)
|
||
}
|
||
if !strings.Contains(body, `href="/static/admin.css"`) {
|
||
t.Errorf("%s does not load the admin foundation stylesheet", page.path)
|
||
}
|
||
if page.name == "Lanes" && strings.Count(body, `hx-trigger="every 30s"`) != 1 {
|
||
t.Errorf("%s has %d Lane timers, want exactly 1", page.path, strings.Count(body, `hx-trigger="every 30s"`))
|
||
}
|
||
})
|
||
}
|
||
|
||
req = httptest.NewRequest(http.MethodGet, "/admin/readers", nil)
|
||
req.AddCookie(ownerCookie)
|
||
rr = httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
body = rr.Body.String()
|
||
for _, want := range []string{`id="readers"`, testOwnerID, "Revoke sessions", "Clear marks", "confirmed"} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("readers page lacks %q:\n%s", want, body)
|
||
}
|
||
}
|
||
if n := strings.Count(body, "/revoke"); n != 1 {
|
||
t.Fatalf("roster has %d revoke controls, want 1 (the owner's own row must have none):\n%s", n, body)
|
||
}
|
||
}
|
||
|
||
// Every administrative route is gated the same way, so the test walks the list
|
||
// the router registers rather than naming routes by hand: no session is 401,
|
||
// a signed-in non-owner is 404, and the address is not confirmed to either.
|
||
func TestAdminRoutesAreOwnerOnly(t *testing.T) {
|
||
router, st, _ := oauthWebTestServer(t)
|
||
theirCookie := signInCookie(t, router)
|
||
ownerCookie := sessionCookie(t, st)
|
||
target := strconv.FormatInt(st.OwnerID(), 10)
|
||
|
||
patterns := web.AdminPatterns()
|
||
if len(patterns) == 0 {
|
||
t.Fatal("no administrative routes to test")
|
||
}
|
||
for _, pattern := range patterns {
|
||
method, path, ok := strings.Cut(pattern, " ")
|
||
if !ok {
|
||
t.Fatalf("route pattern %q has no method", pattern)
|
||
}
|
||
path = strings.Replace(path, "{id}", target, 1)
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
cookie *http.Cookie
|
||
want int
|
||
}{
|
||
{"no session", nil, http.StatusUnauthorized},
|
||
{"non-owner", theirCookie, http.StatusNotFound},
|
||
} {
|
||
req := httptest.NewRequest(method, path, nil)
|
||
if tc.cookie != nil {
|
||
req.AddCookie(tc.cookie)
|
||
}
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != tc.want {
|
||
t.Errorf("%s %s as %s: status = %d, want %d", method, path, tc.name, rr.Code, tc.want)
|
||
}
|
||
}
|
||
|
||
req := httptest.NewRequest(method, path, nil)
|
||
req.AddCookie(ownerCookie)
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code == http.StatusUnauthorized {
|
||
t.Errorf("%s %s as the owner: status = 401, the gate rejects the owner", method, path)
|
||
}
|
||
}
|
||
}
|
||
|
||
// The Lane block reports what the poller says, and marks the Lanes that need
|
||
// attention — a clamped gap, a refusal, a Site whose pages can only be read
|
||
// through a sidecar that is not there, and a Lane with Series waiting that its
|
||
// last pass did not read.
|
||
func TestAdminPageShowsLaneStatus(t *testing.T) {
|
||
lanes := fakeLanes{latest.Status{
|
||
Lanes: []latest.LaneState{
|
||
{Site: "asura", Due: 12, Checked: 12, LastRun: time.Now().Add(-90 * time.Second), Gap: 40 * time.Second},
|
||
{Site: "kagane", Due: 3, Checked: 3, LastRun: time.Now().Add(-time.Minute), Gap: time.Minute, Browser: true},
|
||
{Site: "demonic", Due: 400, Checked: 400, LastRun: time.Now(), Gap: 8 * time.Second, Clamped: true},
|
||
{Site: "comix", Due: 7, LastRun: time.Now(), Gap: time.Minute, Browser: true},
|
||
},
|
||
BrowserConfigured: true,
|
||
BrowserReachable: true,
|
||
}}
|
||
router, st, _ := oauthWebTestServer(t, lanes)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
for _, want := range []string{"asura", "kagane", "12 due", "12 checked", "gap 40s", "ran 1m30s ago", "gap at floor", "not checking", "reachable"} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("lane status lacks %q:\n%s", want, body)
|
||
}
|
||
}
|
||
// Nothing is refusing and the sidecar is up, so neither mark may appear:
|
||
// a mark the owner cannot act on is worse than none.
|
||
for _, unwanted := range []string{"refusing", "no browser"} {
|
||
if strings.Contains(body, unwanted) {
|
||
t.Errorf("lane status marks %q on a healthy run:\n%s", unwanted, body)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A browser Lane under both wake thresholds holds Chrome asleep (ADR-0005), so
|
||
// Series due with none checked is the design working, not a stopped Lane. The
|
||
// two must not render the same mark: "not checking" is the owner's cue to go
|
||
// looking, and spending it on the commonest healthy browser-Lane state trains
|
||
// them to ignore it.
|
||
func TestAsleepBrowserLaneIsNotMarkedStalled(t *testing.T) {
|
||
lanes := fakeLanes{latest.Status{
|
||
Lanes: []latest.LaneState{
|
||
{Site: "kagane", Due: 1, LastRun: time.Now(), Gap: 10 * time.Second, Browser: true, Asleep: true},
|
||
},
|
||
BrowserConfigured: true,
|
||
BrowserReachable: true,
|
||
}}
|
||
router, st, _ := oauthWebTestServer(t, lanes)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
body := rr.Body.String()
|
||
if strings.Contains(body, "not checking") {
|
||
t.Errorf("an asleep browser Lane is marked as stalled:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, "browser asleep") {
|
||
t.Errorf("an asleep browser Lane says nothing about why it read nothing:\n%s", body)
|
||
}
|
||
if strings.Contains(body, `class="attention"`) {
|
||
t.Errorf("an asleep browser Lane is coloured as unhealthy:\n%s", body)
|
||
}
|
||
}
|
||
|
||
// A Lane whose pass never reached a figure must not have that figure drawn as
|
||
// a zero: a refusing Lane still reports the due count and gap its last real
|
||
// pass saw, and a Lane that has never reached one omits it entirely.
|
||
func TestLaneStatusOmitsUnknownGap(t *testing.T) {
|
||
lanes := fakeLanes{latest.Status{
|
||
Lanes: []latest.LaneState{{Site: "comix", LastRun: time.Now(), Refusing: true, Browser: true}},
|
||
BrowserConfigured: true,
|
||
BrowserReachable: true,
|
||
}}
|
||
router, st, _ := oauthWebTestServer(t, lanes)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
body := rr.Body.String()
|
||
if strings.Contains(body, "gap 0s") {
|
||
t.Errorf("a Lane with no pace yet states a zero gap:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, "refusing") {
|
||
t.Errorf("a refusing Lane is not marked as such:\n%s", body)
|
||
}
|
||
}
|
||
|
||
// No poller and a poller that has not finished a pass both render "no data
|
||
// yet" rather than zeroes that read as a stopped backend — but they are not
|
||
// the same fact, so the page must not blame the sidecar when nothing polls.
|
||
func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
lanes []web.LaneReporter
|
||
want, unwant string
|
||
}{
|
||
{"no poller", nil, "Polling is switched off", "not configured"},
|
||
{"poller, no pass yet", []web.LaneReporter{fakeLanes{}}, "not configured", "Polling is switched off"},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
router, st, _ := oauthWebTestServer(t, tc.lanes...)
|
||
req := httptest.NewRequest(http.MethodGet, "/admin/lanes", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
body := rr.Body.String()
|
||
if !strings.Contains(body, "No data yet") {
|
||
t.Errorf("admin page with no Lane data does not say so:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, tc.want) {
|
||
t.Errorf("admin page lacks %q:\n%s", tc.want, body)
|
||
}
|
||
if strings.Contains(body, tc.unwant) {
|
||
t.Errorf("admin page states %q, which is not what is wrong:\n%s", tc.unwant, body)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// A Reader past the disagreement threshold is rendered as blocked, and
|
||
// clearing their marks both zeroes the counters and lifts the block in the
|
||
// roster the response carries back.
|
||
func TestOwnerClearsReaderMarks(t *testing.T) {
|
||
st, dsn := newTestStoreURL(t)
|
||
router := newRouter(st, testConfig(), nil)
|
||
cookie := sessionCookie(t, st)
|
||
// The counters are filled by issue #103; until it lands the only way to
|
||
// stand a marked Reader up is to write the columns directly.
|
||
db, err := sql.Open("pgx", dsn)
|
||
if err != nil {
|
||
t.Fatalf("open %s: %v", dsn, err)
|
||
}
|
||
defer db.Close()
|
||
if _, err := db.Exec(`UPDATE readers SET sighting_agreements = 4, sighting_disagreements = 3 WHERE id = $1`, st.OwnerID()); err != nil {
|
||
t.Fatalf("mark reader: %v", err)
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/admin/readers", nil)
|
||
req.AddCookie(cookie)
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
body := rr.Body.String()
|
||
if !strings.Contains(body, "4 confirmed / 3 contradicted") {
|
||
t.Errorf("roster does not report the Reader's marks:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, "deferral blocked") {
|
||
t.Errorf("a Reader at the threshold is not rendered as blocked:\n%s", body)
|
||
}
|
||
|
||
req = httptest.NewRequest(http.MethodPost,
|
||
"/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/clear-marks", nil)
|
||
req.AddCookie(cookie)
|
||
rr = httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("clear marks: status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||
}
|
||
body = rr.Body.String()
|
||
if !strings.Contains(body, `id="readers"`) {
|
||
t.Fatalf("clear marks did not re-render the roster:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, "0 confirmed / 0 contradicted") {
|
||
t.Errorf("roster does not report the cleared counters:\n%s", body)
|
||
}
|
||
if strings.Contains(body, "deferral blocked") {
|
||
t.Errorf("a cleared Reader is still marked blocked:\n%s", body)
|
||
}
|
||
}
|
||
|
||
func TestDiscordLoginTokenEndpointDown(t *testing.T) {
|
||
stub, srv := newDiscordStub(t)
|
||
stub.tokenStatus = http.StatusInternalServerError
|
||
cfg := testConfig()
|
||
cfg.Discord = discordConfig(srv.URL)
|
||
router, _ := newWebTestServer(t, cfg)
|
||
|
||
rr := completeSignIn(t, router, startSignIn(t, router))
|
||
if rr.Code != http.StatusBadGateway {
|
||
t.Fatalf("status = %d, want 502", rr.Code)
|
||
}
|
||
if !strings.Contains(rr.Body.String(), "unavailable") {
|
||
t.Fatalf("body = %q, want the unavailable message", rr.Body.String())
|
||
}
|
||
if len(rr.Result().Cookies()) != 0 {
|
||
t.Fatal("a failed sign-in set a cookie")
|
||
}
|
||
}
|
||
|
||
func TestCallbackRateLimited(t *testing.T) {
|
||
srv, _, _ := oauthWebTestServer(t)
|
||
call := func() *httptest.ResponseRecorder {
|
||
req := httptest.NewRequest(http.MethodGet,
|
||
"/auth/discord/callback?code=x&state=not-the-state", nil)
|
||
req.Header.Set("X-Forwarded-For", "203.0.113.9")
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
return rr
|
||
}
|
||
for i := 0; i < session.MaxFailures; i++ {
|
||
if code := call().Code; code != http.StatusBadRequest {
|
||
t.Fatalf("attempt %d status = %d, want 400", i+1, code)
|
||
}
|
||
}
|
||
rr := call()
|
||
if rr.Code != http.StatusTooManyRequests {
|
||
t.Fatalf("attempt %d status = %d, want 429", session.MaxFailures+1, rr.Code)
|
||
}
|
||
if after := rr.Header().Get("Retry-After"); after == "" {
|
||
t.Fatal("429 response has no Retry-After header")
|
||
} else if n, err := strconv.Atoi(after); err != nil || n <= 0 {
|
||
t.Fatalf("Retry-After = %q, want a positive integer", after)
|
||
}
|
||
}
|
||
|
||
func TestLogoutDeletesSession(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
cookie := sessionCookie(t, st)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||
req.AddCookie(cookie)
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
|
||
if rr.Code != http.StatusSeeOther {
|
||
t.Fatalf("POST /logout status = %d, want 303", rr.Code)
|
||
}
|
||
cookies := rr.Result().Cookies()
|
||
if len(cookies) != 1 || cookies[0].MaxAge >= 0 {
|
||
t.Fatalf("POST /logout cookies = %+v, want one expiring cookie", cookies)
|
||
}
|
||
// The row is gone, so the same cookie is dead on the next request.
|
||
if _, ok, _ := st.GetSession(cookie.Value, time.Now()); ok {
|
||
t.Fatal("session row still present after logout")
|
||
}
|
||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||
req.AddCookie(cookie)
|
||
rr = httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
if !strings.Contains(rr.Body.String(), "Continue with Discord") {
|
||
t.Fatal("GET / after logout still rendered the library")
|
||
}
|
||
}
|
||
|
||
func TestExpiredSessionRejected(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
sess, err := st.CreateSession(session.NewID(), st.OwnerID(), -time.Minute)
|
||
if err != nil {
|
||
t.Fatalf("CreateSession: %v", err)
|
||
}
|
||
cookie := &http.Cookie{Name: session.CookieName, Value: sess.ID}
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
req.AddCookie(cookie)
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Continue with Discord") {
|
||
t.Fatalf("GET / with an expired session = %d, want the login page", rr.Code)
|
||
}
|
||
|
||
req = httptest.NewRequest(http.MethodGet, "/ui/list", nil)
|
||
req.AddCookie(cookie)
|
||
rr = httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusUnauthorized {
|
||
t.Fatalf("GET /ui/list with an expired session = %d, want 401", rr.Code)
|
||
}
|
||
}
|
||
|
||
func TestBookmarksAPIStillBearerOnly(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
|
||
// A session cookie must not grant access to the userscript's JSON API.
|
||
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusUnauthorized {
|
||
t.Fatalf("GET /bookmarks with only a cookie = %d, want 401", rr.Code)
|
||
}
|
||
|
||
// And the bearer token must still work.
|
||
rr = httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("GET /bookmarks with bearer = %d, want 200", rr.Code)
|
||
}
|
||
}
|
||
|
||
func TestStaticAssetsServed(t *testing.T) {
|
||
srv, _ := newWebTestServer(t, testConfig())
|
||
for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js", "/static/logo.svg", "/static/login-art.png"} {
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("GET %s = %d, want 200", path, rr.Code)
|
||
}
|
||
if rr.Body.Len() == 0 {
|
||
t.Fatalf("GET %s returned an empty body", path)
|
||
}
|
||
}
|
||
}
|
||
|
||
// seed inserts one bookmark and returns it as stored.
|
||
func seed(t *testing.T, st *store.Store, b store.Bookmark) store.Bookmark {
|
||
t.Helper()
|
||
stored, err := st.Upsert(st.OwnerID(), b)
|
||
if err != nil {
|
||
t.Fatalf("Upsert: %v", err)
|
||
}
|
||
return stored
|
||
}
|
||
|
||
func uiRequest(t *testing.T, st *store.Store, method, path string, form url.Values) *http.Request {
|
||
t.Helper()
|
||
var req *http.Request
|
||
if form == nil {
|
||
req = httptest.NewRequest(method, path, nil)
|
||
} else {
|
||
req = httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
}
|
||
req.AddCookie(sessionCookie(t, st))
|
||
return req
|
||
}
|
||
|
||
func TestUIRoutesRequireSession(t *testing.T) {
|
||
srv, _ := newWebTestServer(t, testConfig())
|
||
cases := []struct{ method, path string }{
|
||
{http.MethodGet, "/ui/list"},
|
||
{http.MethodPost, "/ui/bookmarks/asura:solo/favorite"},
|
||
{http.MethodPost, "/ui/bookmarks/asura:solo/chapter"},
|
||
{http.MethodDelete, "/ui/bookmarks/asura:solo"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
|
||
if rr.Code != http.StatusUnauthorized {
|
||
t.Fatalf("status = %d, want 401", rr.Code)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
before := seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||
UpdatedAt: 1_000_000,
|
||
})
|
||
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("favorite status = %d, want 200", rr.Code)
|
||
}
|
||
|
||
after, ok, err := st.Get(st.OwnerID(), "asura:solo")
|
||
if err != nil || !ok {
|
||
t.Fatalf("Get after favorite: %v ok=%v", err, ok)
|
||
}
|
||
if !after.Favorite {
|
||
t.Fatal("Favorite = false after toggling, want true")
|
||
}
|
||
if after.UpdatedAt != before.UpdatedAt {
|
||
t.Fatalf("UpdatedAt moved from %d to %d; favouriting must not reorder the list",
|
||
before.UpdatedAt, after.UpdatedAt)
|
||
}
|
||
if !strings.Contains(rr.Body.String(), `id="card-asura:solo"`) {
|
||
t.Fatal("favorite response did not render the card fragment")
|
||
}
|
||
|
||
// Toggling again turns it back off.
|
||
rr = httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
|
||
back, _, _ := st.Get(st.OwnerID(), "asura:solo")
|
||
if back.Favorite {
|
||
t.Fatal("Favorite = true after a second toggle, want false")
|
||
}
|
||
}
|
||
|
||
// TestCardHxTargetIsValidSelectorForColonKey asserts the rendered card's
|
||
// hx-target attributes use the fixed-string attribute-selector form
|
||
// ([id='card-<key>']) rather than a bare CSS id-selector (#card-<key>).
|
||
//
|
||
// A key like "asura:solo" makes "#card-asura:solo" an invalid CSS selector:
|
||
// the browser parses ":solo" as an unrecognised pseudo-class and htmx's
|
||
// querySelectorAll throws SyntaxError, so the button never resolves its
|
||
// swap target. httptest never executes htmx, so this only checks the
|
||
// rendered attribute's shape — it is not proof the browser accepts the
|
||
// selector, just a regression guard against reintroducing the bare-id form.
|
||
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||
UpdatedAt: 1_000_000,
|
||
})
|
||
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodGet, "/ui/list", nil))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
|
||
want := `hx-target="[id='card-asura:solo']"`
|
||
if strings.Count(body, want) != 5 {
|
||
t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, delete buttons, chapter form)",
|
||
strings.Count(body, want), want)
|
||
}
|
||
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
|
||
t.Fatal("body still uses the bare id CSS selector, which is invalid for a key containing ':'")
|
||
}
|
||
}
|
||
|
||
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
before := seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
||
UpdatedAt: 1_000_000,
|
||
})
|
||
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodPost,
|
||
"/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {"60"}}))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("chapter override status = %d, want 200", rr.Code)
|
||
}
|
||
|
||
after, ok, err := st.Get(st.OwnerID(), "asura:solo")
|
||
if err != nil || !ok {
|
||
t.Fatalf("Get after override: %v ok=%v", err, ok)
|
||
}
|
||
if after.LastChapterNum != 60 || after.LastChapter != "60" {
|
||
t.Fatalf("chapter = %q/%v, want 60", after.LastChapter, after.LastChapterNum)
|
||
}
|
||
if after.UpdatedAt <= before.UpdatedAt {
|
||
t.Fatalf("UpdatedAt = %d, want later than %d", after.UpdatedAt, before.UpdatedAt)
|
||
}
|
||
if after.LastChapterURL != "" {
|
||
t.Fatalf("LastChapterURL = %q, want cleared by a manual override", after.LastChapterURL)
|
||
}
|
||
if after.Title != "Solo Leveling" {
|
||
t.Fatalf("Title = %q, want the untouched fields preserved", after.Title)
|
||
}
|
||
}
|
||
|
||
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
before := seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45,
|
||
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
||
UpdatedAt: 1_000_000,
|
||
})
|
||
|
||
// The chapter form is pre-filled with the current value, so tapping Save
|
||
// without editing resubmits the unchanged number. That must be a no-op: it
|
||
// must not clear last_chapter_url, rewrite the last_chapter display string,
|
||
// or move updated_at. The seed stores "45.0" against 45 so the display
|
||
// string differs from what the form submits back.
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodPost,
|
||
"/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {"45"}}))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
|
||
}
|
||
|
||
after, ok, err := st.Get(st.OwnerID(), "asura:solo")
|
||
if err != nil || !ok {
|
||
t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
|
||
}
|
||
if after.LastChapterURL != before.LastChapterURL {
|
||
t.Fatalf("LastChapterURL = %q, want preserved %q on a no-op save",
|
||
after.LastChapterURL, before.LastChapterURL)
|
||
}
|
||
if after.LastChapter != before.LastChapter {
|
||
t.Fatalf("LastChapter = %q, want preserved %q on a no-op save",
|
||
after.LastChapter, before.LastChapter)
|
||
}
|
||
if after.UpdatedAt != before.UpdatedAt {
|
||
t.Fatalf("UpdatedAt = %d, want unchanged %d on a no-op save",
|
||
after.UpdatedAt, before.UpdatedAt)
|
||
}
|
||
}
|
||
|
||
func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
|
||
})
|
||
|
||
for _, bad := range []string{"", "abc", "-3", "NaN", "Infinity", "-Inf"} {
|
||
t.Run("input "+bad, func(t *testing.T) {
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodPost,
|
||
"/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {bad}}))
|
||
if rr.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400", rr.Code)
|
||
}
|
||
after, _, _ := st.Get(st.OwnerID(), "asura:solo")
|
||
if after.LastChapterNum != 45 {
|
||
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestMutationsOnMissingKey(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
cases := []struct {
|
||
name string
|
||
req *http.Request
|
||
}{
|
||
{"favorite", uiRequest(t, st, http.MethodPost, "/ui/bookmarks/asura:nope/favorite", nil)},
|
||
{"chapter", uiRequest(t, st, http.MethodPost, "/ui/bookmarks/asura:nope/chapter", url.Values{"chapter": {"1"}})},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, tc.req)
|
||
if rr.Code != http.StatusNotFound {
|
||
t.Fatalf("status = %d, want 404", rr.Code)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestUIDeleteRemovesRow(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", UpdatedAt: 1_000_000,
|
||
})
|
||
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodDelete, "/ui/bookmarks/asura:solo", nil))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("delete status = %d, want 200", rr.Code)
|
||
}
|
||
// The body carries only out-of-band chrome, so htmx has nothing to swap into
|
||
// the card's place and the row disappears.
|
||
body := rr.Body.String()
|
||
if strings.Contains(body, `class="card`) {
|
||
t.Fatalf("delete body = %q, want no card so htmx swaps it away", body)
|
||
}
|
||
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
|
||
t.Fatalf("delete body = %q, want the out-of-band badge", body)
|
||
}
|
||
if _, ok, _ := st.Get(st.OwnerID(), "asura:solo"); ok {
|
||
t.Fatal("row still present after delete")
|
||
}
|
||
}
|
||
|
||
func TestUIListFavouritesTab(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
|
||
})
|
||
seed(t, st, store.Bookmark{
|
||
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
||
Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000,
|
||
})
|
||
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodGet, "/ui/list?tab=fav", nil))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
if !strings.Contains(body, "Solo Leveling") {
|
||
t.Fatal("favourites tab omitted the favourited series")
|
||
}
|
||
if strings.Contains(body, "Tower of God") {
|
||
t.Fatal("favourites tab included a non-favourite")
|
||
}
|
||
}
|
||
|
||
func TestUIListNewTab(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", LastChapterNum: 10,
|
||
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
|
||
UpdatedAt: 2_000_000,
|
||
})
|
||
seed(t, st, store.Bookmark{
|
||
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
||
Title: "Tower of God", LastChapterNum: 5,
|
||
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
|
||
UpdatedAt: 1_000_000,
|
||
})
|
||
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodGet, "/ui/list?tab=new", nil))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
if !strings.Contains(body, "Solo Leveling") {
|
||
t.Fatal("new tab omitted the series with an unread chapter")
|
||
}
|
||
if strings.Contains(body, "Tower of God") {
|
||
t.Fatal("new tab included a series already caught up")
|
||
}
|
||
}
|
||
|
||
// seedStatusRows puts one series in each bucket, the archived one also
|
||
// favourited and with a new chapter out, so a leak into any reading-bucket tab
|
||
// shows up as a failure rather than passing by accident.
|
||
func seedStatusRows(t *testing.T, st *store.Store) {
|
||
t.Helper()
|
||
// floatPtr already exists in store_test.go — same package, reuse it.
|
||
rows := []store.Bookmark{
|
||
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
|
||
Status: store.StatusReading, LastChapterNum: 10, Favorite: true,
|
||
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
|
||
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
||
Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
|
||
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
||
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
|
||
Status: store.StatusFinished, LastChapterNum: 200, Favorite: true},
|
||
}
|
||
for _, b := range rows {
|
||
b.UpdatedAt = time.Now().UnixMilli()
|
||
if _, err := st.Upsert(st.OwnerID(), b); err != nil {
|
||
t.Fatalf("seed %s: %v", b.Key, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seedStatusRows(t, st)
|
||
|
||
cases := []struct {
|
||
tab string
|
||
want, dontWant []string
|
||
}{
|
||
{"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||
{"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}},
|
||
{"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.tab, func(t *testing.T) {
|
||
req := httptest.NewRequest(http.MethodGet, "/ui/list?tab="+tc.tab, nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
for _, w := range tc.want {
|
||
if !strings.Contains(body, w) {
|
||
t.Fatalf("tab %s missing %s", tc.tab, w)
|
||
}
|
||
}
|
||
for _, d := range tc.dontWant {
|
||
if strings.Contains(body, d) {
|
||
t.Fatalf("tab %s leaked %s", tc.tab, d)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// stripOf returns everything above the list, which is where the recent section
|
||
// renders.
|
||
func stripOf(t *testing.T, srv http.Handler, st *store.Store, tab string) string {
|
||
t.Helper()
|
||
req := httptest.NewRequest(http.MethodGet, "/?tab="+tab, nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
body := rr.Body.String()
|
||
if i := strings.Index(body, `id="list"`); i >= 0 {
|
||
body = body[:i]
|
||
}
|
||
return body
|
||
}
|
||
|
||
// The strip carries the series with a chapter waiting — the one thing the
|
||
// updated_at-ordered list below it does not already say — and only on All.
|
||
func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading
|
||
|
||
// A reading series that is caught up has nothing waiting, so it stays out.
|
||
caught := store.Bookmark{
|
||
Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne",
|
||
Status: store.StatusReading, LastChapterNum: 40, LatestChapter: "40",
|
||
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
|
||
}
|
||
if _, err := st.Upsert(st.OwnerID(), caught); err != nil {
|
||
t.Fatalf("seed %s: %v", caught.Key, err)
|
||
}
|
||
|
||
strip := stripOf(t, srv, st, "all")
|
||
if !strings.Contains(strip, "ReadingOne") {
|
||
t.Fatal("strip dropped the series with an unread chapter")
|
||
}
|
||
for _, unwanted := range []string{"CaughtUpOne", "ArchivedOne", "FinishedOne"} {
|
||
if strings.Contains(strip, unwanted) {
|
||
t.Fatalf("strip included %s", unwanted)
|
||
}
|
||
}
|
||
for _, tab := range []string{"new", "fav", "archived", "finished"} {
|
||
if strings.Contains(stripOf(t, srv, st, tab), "ReadingOne") {
|
||
t.Fatalf("tab %s rendered the strip", tab)
|
||
}
|
||
}
|
||
|
||
// Nothing new anywhere: the strip has nothing to say and does not render.
|
||
reading, _, err := st.Get(st.OwnerID(), "asura:reading")
|
||
if err != nil {
|
||
t.Fatalf("Get: %v", err)
|
||
}
|
||
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
|
||
if _, err := st.Upsert(st.OwnerID(), reading); err != nil {
|
||
t.Fatalf("Upsert: %v", err)
|
||
}
|
||
// The section still ships (an out-of-band swap needs the id to exist) but
|
||
// carries no cards and is hidden.
|
||
empty := stripOf(t, srv, st, "all")
|
||
if strings.Contains(empty, "recent-card") {
|
||
t.Fatal("strip rendered cards with no unread chapters anywhere")
|
||
}
|
||
if !strings.Contains(empty, `id="recent" hidden`) {
|
||
t.Fatalf("strip not hidden with nothing new: %q", empty)
|
||
}
|
||
}
|
||
|
||
// The strip never grows past web.RecentCount, however many series are waiting.
|
||
func TestRecentStripCapped(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
for i := 0; i <= web.RecentCount; i++ {
|
||
b := store.Bookmark{
|
||
Key: fmt.Sprintf("asura:new%d", i), Site: "asura",
|
||
SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i),
|
||
Status: store.StatusReading, LastChapterNum: 1, LatestChapter: "2",
|
||
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
|
||
}
|
||
if _, err := st.Upsert(st.OwnerID(), b); err != nil {
|
||
t.Fatalf("seed %s: %v", b.Key, err)
|
||
}
|
||
}
|
||
if got := strings.Count(stripOf(t, srv, st, "all"), "recent-card"); got != web.RecentCount {
|
||
t.Fatalf("strip rendered %d cards, want %d", got, web.RecentCount)
|
||
}
|
||
}
|
||
|
||
func postStatus(t *testing.T, srv http.Handler, st *store.Store, key, status string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
form := url.Values{"status": {status}}
|
||
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/"+key+"/status",
|
||
strings.NewReader(form.Encode()))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
return rr
|
||
}
|
||
|
||
func TestUIStatusSetsBucket(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seedStatusRows(t, st)
|
||
|
||
for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} {
|
||
if rr := postStatus(t, srv, st, "asura:reading", want); rr.Code != http.StatusOK {
|
||
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
||
}
|
||
b, ok, err := st.Get(st.OwnerID(), "asura:reading")
|
||
if err != nil || !ok {
|
||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||
}
|
||
if b.Status != want {
|
||
t.Fatalf("stored status = %q, want %q", b.Status, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seedStatusRows(t, st)
|
||
|
||
if rr := postStatus(t, srv, st, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400", rr.Code)
|
||
}
|
||
b, _, _ := st.Get(st.OwnerID(), "asura:reading")
|
||
if b.Status != store.StatusReading {
|
||
t.Fatalf("stored status = %q, want it untouched", b.Status)
|
||
}
|
||
}
|
||
|
||
func TestUIStatusRequiresSession(t *testing.T) {
|
||
srv, st := newWebTestServer(t, testConfig())
|
||
seedStatusRows(t, st)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
|
||
strings.NewReader("status=archived"))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
|
||
if rr.Code != http.StatusUnauthorized {
|
||
t.Fatalf("status = %d, want 401", rr.Code)
|
||
}
|
||
}
|
||
|
||
func TestUIStatusDoesNotReorderList(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seedStatusRows(t, st)
|
||
|
||
before, _, _ := st.Get(st.OwnerID(), "asura:reading")
|
||
time.Sleep(2 * time.Millisecond)
|
||
if rr := postStatus(t, srv, st, "asura:reading", store.StatusArchived); rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", rr.Code)
|
||
}
|
||
after, _, _ := st.Get(st.OwnerID(), "asura:reading")
|
||
if after.UpdatedAt != before.UpdatedAt {
|
||
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
|
||
}
|
||
}
|
||
|
||
func TestCardShowsStatusControls(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seedStatusRows(t, st)
|
||
|
||
cases := []struct {
|
||
tab string
|
||
want, dontWant []string
|
||
}{
|
||
// A series being read can be shelved or completed, not restored.
|
||
{"all", []string{`hx-vals='{"status":"archived"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
||
// An archived one can come back or be completed.
|
||
{"archived", []string{`hx-vals='{"status":"reading"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
||
// A finished one can only come back.
|
||
{"finished", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.tab, func(t *testing.T) {
|
||
req := httptest.NewRequest(http.MethodGet, "/ui/list?tab="+tc.tab, nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
|
||
body := rr.Body.String()
|
||
for _, w := range tc.want {
|
||
if !strings.Contains(body, w) {
|
||
t.Fatalf("tab %s missing control %s", tc.tab, w)
|
||
}
|
||
}
|
||
for _, d := range tc.dontWant {
|
||
if strings.Contains(body, d) {
|
||
t.Fatalf("tab %s offered %s", tc.tab, d)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestAppRendersNewTabs(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seedStatusRows(t, st)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
|
||
for _, want := range []string{`href="/?tab=archived"`, `href="/?tab=finished"`} {
|
||
if !strings.Contains(rr.Body.String(), want) {
|
||
t.Fatalf("app page missing %s", want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A mutation has to bring the chrome with it: the strip and the badge live
|
||
// outside the swapped card, so nothing else would correct them.
|
||
func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
|
||
Status: store.StatusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
|
||
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
|
||
})
|
||
|
||
before := stripOf(t, srv, st, "all")
|
||
if !strings.Contains(before, "Solo Leveling") || !strings.Contains(before, `id="new-count"`) {
|
||
t.Fatalf("expected the series in the strip to start with: %q", before)
|
||
}
|
||
|
||
req := uiRequest(t, st, http.MethodPost, "/ui/bookmarks/asura:solo/status",
|
||
url.Values{"status": {store.StatusArchived}})
|
||
req.Header.Set("HX-Current-URL", "http://localhost/?tab=all")
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status post = %d, want 200", rr.Code)
|
||
}
|
||
|
||
body := rr.Body.String()
|
||
if !strings.Contains(body, `id="recent" hx-swap-oob="true" hidden`) {
|
||
t.Fatalf("archiving did not empty the strip out of band: %q", body)
|
||
}
|
||
if !strings.Contains(body, `id="new-count" hx-swap-oob="true" hidden`) {
|
||
t.Fatalf("archiving did not clear the Updated badge out of band: %q", body)
|
||
}
|
||
}
|
||
|
||
// seedLibraries puts one manga and one novel row in the store.
|
||
func seedLibraries(t *testing.T, st *store.Store) {
|
||
t.Helper()
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", Kind: store.KindManga, UpdatedAt: 2_000_000,
|
||
})
|
||
seed(t, st, store.Bookmark{
|
||
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
||
SeriesID: "a-will-eternal", Title: "A Will Eternal",
|
||
Kind: store.KindNovel, UpdatedAt: 1_000_000,
|
||
})
|
||
}
|
||
|
||
func TestLibrariesAreDisjoint(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seedLibraries(t, st)
|
||
|
||
cases := []struct {
|
||
name, path, want, absent string
|
||
}{
|
||
{"manga is the default", "/ui/list?tab=all", "Solo Leveling", "A Will Eternal"},
|
||
{"novel is opt-in", "/ui/list?lib=novel&tab=all", "A Will Eternal", "Solo Leveling"},
|
||
{"unknown lib falls back to manga", "/ui/list?lib=comics&tab=all", "Solo Leveling", "A Will Eternal"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodGet, tc.path, nil))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
if !strings.Contains(body, tc.want) {
|
||
t.Fatalf("%s missing from %s", tc.want, tc.path)
|
||
}
|
||
if strings.Contains(body, tc.absent) {
|
||
t.Fatalf("%s leaked into %s", tc.absent, tc.path)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// A row written before the kind column existed has none. It is manga.
|
||
func TestKindlessRowShowsInMangaLibrary(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:legacy", Site: "asura", SeriesID: "legacy",
|
||
Title: "Legacy Series", UpdatedAt: 1_000_000,
|
||
})
|
||
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodGet, "/ui/list?tab=all", nil))
|
||
if !strings.Contains(rr.Body.String(), "Legacy Series") {
|
||
t.Fatal("a row with no kind must appear in the manga library")
|
||
}
|
||
}
|
||
|
||
func TestNovelPageOmitsUpdatedTab(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seedLibraries(t, st)
|
||
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodGet, "/?lib=novel&tab=all", nil))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
if strings.Contains(body, "tab=new") {
|
||
t.Fatal("novel page must not offer the Updated tab")
|
||
}
|
||
// html/template escapes & to & inside an attribute value, so that — not
|
||
// the raw URL — is what lands in the body. htmx and the browser both decode
|
||
// it on read, so only the assertion has to know.
|
||
for _, want := range []string{
|
||
"/?lib=novel&tab=fav",
|
||
"/?lib=novel&tab=archived",
|
||
"/?lib=novel&tab=finished",
|
||
} {
|
||
if !strings.Contains(body, want) {
|
||
t.Fatalf("novel page missing tab link %s", want)
|
||
}
|
||
}
|
||
if !strings.Contains(body, `class="libswitch"`) {
|
||
t.Fatal("novel page missing the library switch")
|
||
}
|
||
}
|
||
|
||
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seedLibraries(t, st)
|
||
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodGet, "/?tab=all", nil))
|
||
body := rr.Body.String()
|
||
if !strings.Contains(body, "/?tab=new") {
|
||
t.Fatal("manga page must keep the Updated tab")
|
||
}
|
||
if strings.Contains(body, "lib=novel&tab=new") {
|
||
t.Fatal("the Updated tab must never be emitted for the novel library")
|
||
}
|
||
}
|
||
|
||
// tab=new is not offered for novels, so a hand-typed one must land on All
|
||
// rather than an empty page.
|
||
func TestNovelNewTabFallsBackToAll(t *testing.T) {
|
||
cfg := testConfig()
|
||
srv, st := newWebTestServer(t, cfg)
|
||
seedLibraries(t, st)
|
||
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodGet, "/ui/list?lib=novel&tab=new", nil))
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rr.Code)
|
||
}
|
||
if !strings.Contains(rr.Body.String(), "A Will Eternal") {
|
||
t.Fatal("novel tab=new should render the novel All list")
|
||
}
|
||
}
|
||
|
||
// seriesRowSeed is one series row (and optionally its bookmarks) for the
|
||
// Series list tests. Seeded with direct SQL because the store's own surface
|
||
// cannot produce an orphan series or a Reader-raised Latest Chapter — the
|
||
// same reason the store's admin tests seed this way.
|
||
type seriesRowSeed struct {
|
||
key string
|
||
kind string
|
||
url string
|
||
cover string // cover_address
|
||
checkedAt int64
|
||
latestNum *float64
|
||
bookmarks int // readers that hold it; 0 = orphan
|
||
raisedBy bool // a Reader's report is attributed as the raiser
|
||
}
|
||
|
||
// seedSeriesRow inserts one series row and its bookmarks (owner first, then
|
||
// fresh readers) with the exact admin-relevant facts a test needs.
|
||
func seedSeriesRow(t *testing.T, st *store.Store, db *sql.DB, seed seriesRowSeed) {
|
||
t.Helper()
|
||
site, seriesID, ok := strings.Cut(seed.key, ":")
|
||
if !ok {
|
||
t.Fatalf("key %q: no ':' separator", seed.key)
|
||
}
|
||
if seed.kind == "" {
|
||
seed.kind = store.KindManga
|
||
}
|
||
var latestChapter any = ""
|
||
if seed.latestNum != nil {
|
||
latestChapter = "Chapter " + strconv.FormatFloat(*seed.latestNum, 'f', -1, 64)
|
||
}
|
||
if _, err := db.Exec(`
|
||
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
|
||
latest_checked_at, latest_chapter, latest_chapter_num)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
|
||
seed.checkedAt, latestChapter, seed.latestNum); err != nil {
|
||
t.Fatalf("seed series %q: %v", seed.key, err)
|
||
}
|
||
for i := range seed.bookmarks {
|
||
var readerID int64 = st.OwnerID()
|
||
if i > 0 {
|
||
readerID = seedReader(t, st)
|
||
}
|
||
if _, err := db.Exec(`
|
||
INSERT INTO bookmarks (reader_id, site, series_id,
|
||
last_chapter, last_chapter_num, last_chapter_url,
|
||
favorite, status, updated_at)
|
||
VALUES ($1, $2, $3, '', 0, '', false, 'reading', $4)`,
|
||
readerID, site, seriesID, seed.checkedAt); err != nil {
|
||
t.Fatalf("seed bookmark %q: %v", seed.key, err)
|
||
}
|
||
}
|
||
if seed.raisedBy {
|
||
if _, err := db.Exec(
|
||
`UPDATE series SET latest_raised_by = $1 WHERE site = $2 AND series_id = $3`,
|
||
st.OwnerID(), site, seriesID); err != nil {
|
||
t.Fatalf("seed raised-by %q: %v", seed.key, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// seedReader mints a fresh Reader for a second bookmark, so a series can carry
|
||
// a Reader count above one.
|
||
func seedReader(t *testing.T, st *store.Store) int64 {
|
||
t.Helper()
|
||
discordID := "seed-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||
id, err := st.EnsureReader(discordID, [32]byte{})
|
||
if err != nil {
|
||
t.Fatalf("EnsureReader: %v", err)
|
||
}
|
||
return id
|
||
}
|
||
|
||
// adminSeriesPage drives one Series list request as the owner and returns the
|
||
// rendered body, failing the test on anything but a 200.
|
||
func adminSeriesPage(t *testing.T, srv http.Handler, st *store.Store, query string) string {
|
||
t.Helper()
|
||
req := httptest.NewRequest(http.MethodGet, "/admin/series"+query, nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
srv.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("GET /admin/series%s status = %d, want 200", query, rr.Code)
|
||
}
|
||
return rr.Body.String()
|
||
}
|
||
|
||
// The filter select reaches the store as the wire constant and the heading
|
||
// states the same total the rows render: ?filter=no_cover renders only the
|
||
// no-cover row, its option label carries its library-wide count, and an
|
||
// unknown filter value is the absent All case, never an error.
|
||
func TestSeriesListFilterWiring(t *testing.T) {
|
||
st, dsn := newTestStoreURL(t)
|
||
db, err := sql.Open("pgx", dsn)
|
||
if err != nil {
|
||
t.Fatalf("open %s: %v", dsn, err)
|
||
}
|
||
defer db.Close()
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(3), bookmarks: 1})
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "bbb", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(4), bookmarks: 1})
|
||
srv := newRouter(st, testConfig(), nil)
|
||
|
||
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
|
||
if !strings.Contains(body, "Title of asura:nocover") {
|
||
t.Errorf("no_cover list misses its row:\n%s", body)
|
||
}
|
||
if strings.Contains(body, "Title of asura:healthy") || strings.Contains(body, "Title of asura:stale") {
|
||
t.Errorf("no_cover list renders a covered row:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, "1 series") || !strings.Contains(body, "No cover") {
|
||
t.Errorf("no_cover heading lacks the filtered count and name:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, "No cover (1)") {
|
||
t.Errorf("the filter option label lacks its count:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, `<option value="no_cover" selected>`) {
|
||
t.Errorf("the no_cover option is not selected:\n%s", body)
|
||
}
|
||
// The stale option's count is cutoff-dependent: the row read passes the
|
||
// 12h boundary, and so must the aggregate that numbers the select.
|
||
if !strings.Contains(body, "Not checked in 12h (1)") {
|
||
t.Errorf("the stale option lacks its cutoff-dependent count:\n%s", body)
|
||
}
|
||
|
||
// An unknown filter value is the absent All case: both rows, no error.
|
||
body = adminSeriesPage(t, srv, st, "?filter=bogus")
|
||
if !strings.Contains(body, "Title of asura:healthy") || !strings.Contains(body, "Title of asura:nocover") {
|
||
t.Errorf("unknown filter does not render All series:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, `<option value="all" selected>`) {
|
||
t.Errorf("the all option is not selected for an unknown filter:\n%s", body)
|
||
}
|
||
}
|
||
|
||
// ?filter=stale&site=kagane&kind=manga narrows on all three at once: only the
|
||
// kagane manga stale row renders, and every link the page emits carries the
|
||
// filter and Site so the narrowing survives in the URL.
|
||
func TestSeriesListSiteAndKindComposeWithFilter(t *testing.T) {
|
||
st, dsn := newTestStoreURL(t)
|
||
db, err := sql.Open("pgx", dsn)
|
||
if err != nil {
|
||
t.Fatalf("open %s: %v", dsn, err)
|
||
}
|
||
defer db.Close()
|
||
stale := time.Now().Add(-24 * time.Hour).UnixMilli()
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:want", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:novel", url: "u", cover: "c", kind: store.KindNovel, checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:fresh", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
|
||
srv := newRouter(st, testConfig(), nil)
|
||
|
||
body := adminSeriesPage(t, srv, st, "?filter=stale&site=kagane&kind=manga")
|
||
if !strings.Contains(body, "Title of kagane:want") {
|
||
t.Errorf("stale+kagane+manga misses its row:\n%s", body)
|
||
}
|
||
for _, unwanted := range []string{"Title of kagane:novel", "Title of kagane:fresh", "Title of asura:stale"} {
|
||
if strings.Contains(body, unwanted) {
|
||
t.Errorf("stale+kagane+manga renders %q:\n%s", unwanted, body)
|
||
}
|
||
}
|
||
if !strings.Contains(body, "1 series") {
|
||
t.Errorf("heading lacks the narrowed count:\n%s", body)
|
||
}
|
||
// The narrowing survives in the URL: every emitted link carries the
|
||
// filter and Site, and the kind hidden input keeps it on select submits.
|
||
for _, want := range []string{
|
||
`href="/admin/series?filter=stale&site=kagane"`,
|
||
`href="/admin/series?filter=stale&kind=manga&site=kagane"`,
|
||
`href="/admin/series?filter=stale&kind=novel&site=kagane"`,
|
||
`<input type="hidden" name="kind" value="manga">`,
|
||
} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("narrowing is lost from the URL; missing %q:\n%s", want, body)
|
||
}
|
||
}
|
||
if !strings.Contains(body, `<option value="stale" selected>`) {
|
||
t.Errorf("the stale filter is not kept selected:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, `<option value="kagane" selected>`) {
|
||
t.Errorf("the kagane site is not kept selected:\n%s", body)
|
||
}
|
||
}
|
||
|
||
// More than one page of rows: page 1 and page 2 share no key, the pager range
|
||
// comes from the window total (not a second query), and a page past the end
|
||
// re-reads at page 1 rather than rendering an empty table. All 55 rows share
|
||
// a zero check stamp, so only the (site, series_id) tie-break keeps the page
|
||
// boundary stable.
|
||
func TestSeriesListPagingIsStable(t *testing.T) {
|
||
st, dsn := newTestStoreURL(t)
|
||
db, err := sql.Open("pgx", dsn)
|
||
if err != nil {
|
||
t.Fatalf("open %s: %v", dsn, err)
|
||
}
|
||
defer db.Close()
|
||
if _, err := db.Exec(`
|
||
INSERT INTO series (site, series_id, title, series_url, cover_address,
|
||
latest_checked_at)
|
||
SELECT 'asura', 'bulk-' || g, 'Bulk ' || g, 'https://asurascans.com/comics/bulk-' || g, 'c', 0
|
||
FROM generate_series(1, 55) AS g`); err != nil {
|
||
t.Fatalf("bulk seed series: %v", err)
|
||
}
|
||
if _, err := db.Exec(`
|
||
INSERT INTO bookmarks (reader_id, site, series_id, updated_at)
|
||
SELECT $1, 'asura', 'bulk-' || g, 1000
|
||
FROM generate_series(1, 55) AS g`, st.OwnerID()); err != nil {
|
||
t.Fatalf("bulk seed bookmarks: %v", err)
|
||
}
|
||
srv := newRouter(st, testConfig(), nil)
|
||
|
||
pageKeys := func(body string) map[string]bool {
|
||
out := map[string]bool{}
|
||
// parts[0] is the prelude before the first detail link; every later
|
||
// chunk starts with a key, so only those count.
|
||
for _, chunk := range strings.Split(body, `href="/admin/series/`)[1:] {
|
||
if i := strings.Index(chunk, `"`); i > 0 {
|
||
out[chunk[:i]] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
p1 := adminSeriesPage(t, srv, st, "")
|
||
p2 := adminSeriesPage(t, srv, st, "?page=2")
|
||
if !strings.Contains(p1, "1–50 of 55") {
|
||
t.Errorf("page 1 pager range wrong:\n%s", p1)
|
||
}
|
||
if !strings.Contains(p2, "51–55 of 55") {
|
||
t.Errorf("page 2 pager range wrong:\n%s", p2)
|
||
}
|
||
if !strings.Contains(p1, `href="/admin/series?page=2"`) {
|
||
t.Errorf("page 1 lacks a next link:\n%s", p1)
|
||
}
|
||
k1, k2 := pageKeys(p1), pageKeys(p2)
|
||
if len(k1) != 50 || len(k2) != 5 {
|
||
t.Fatalf("pages hold %d and %d rows, want 50 and 5", len(k1), len(k2))
|
||
}
|
||
for k := range k1 {
|
||
if k2[k] {
|
||
t.Errorf("row %q repeats across pages", k)
|
||
}
|
||
}
|
||
if len(k1)+len(k2) != 55 {
|
||
t.Errorf("%d distinct rows across pages, want 55 (a row vanished)", len(k1)+len(k2))
|
||
}
|
||
|
||
// A page past the end re-reads at page 1: the pager states the first
|
||
// page's range and the rows render rather than an empty table.
|
||
pOver := adminSeriesPage(t, srv, st, "?page=99")
|
||
if !strings.Contains(pOver, "1–50 of 55") || len(pageKeys(pOver)) != 50 {
|
||
t.Errorf("a page past the end does not re-read at page 1:\n%s", pOver)
|
||
}
|
||
}
|
||
|
||
// Page 2 of a one-page result re-reads at page 1: the store's window count
|
||
// only runs over the rows the result set carries, so an overflow page must
|
||
// not be rendered as an empty list.
|
||
func TestSeriesListPagePastEndReReadsAtPageOne(t *testing.T) {
|
||
st, dsn := newTestStoreURL(t)
|
||
db, err := sql.Open("pgx", dsn)
|
||
if err != nil {
|
||
t.Fatalf("open %s: %v", dsn, err)
|
||
}
|
||
defer db.Close()
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:c", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||
srv := newRouter(st, testConfig(), nil)
|
||
|
||
body := adminSeriesPage(t, srv, st, "?page=2")
|
||
if !strings.Contains(body, "1–3 of 3") {
|
||
t.Errorf("page 2 of a one-page result does not re-read at page 1:\n%s", body)
|
||
}
|
||
for _, want := range []string{"Title of asura:a", "Title of asura:b", "Title of asura:c"} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("page 2 of a one-page result dropped %q:\n%s", want, body)
|
||
}
|
||
}
|
||
if strings.Contains(body, `class="empty"`) {
|
||
t.Errorf("page 2 of a one-page result renders the empty state:\n%s", body)
|
||
}
|
||
}
|
||
|
||
// A filter matching nothing renders the named empty state, still 200, and
|
||
// keeps the filter selected: an empty hygiene list reads as good news rather
|
||
// than a broken page.
|
||
func TestSeriesListEmptyStateNamesTheFilter(t *testing.T) {
|
||
st, dsn := newTestStoreURL(t)
|
||
db, err := sql.Open("pgx", dsn)
|
||
if err != nil {
|
||
t.Fatalf("open %s: %v", dsn, err)
|
||
}
|
||
defer db.Close()
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:covered", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||
srv := newRouter(st, testConfig(), nil)
|
||
|
||
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
|
||
if !strings.Contains(body, "No series") {
|
||
t.Errorf("a matching-nothing filter renders no named empty state:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, "No cover") {
|
||
t.Errorf("the empty state does not name the filter:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, `<option value="no_cover" selected>`) {
|
||
t.Errorf("the empty filter is not kept selected:\n%s", body)
|
||
}
|
||
if strings.Contains(body, `class="tbl series"`) {
|
||
t.Errorf("an empty list still renders the table:\n%s", body)
|
||
}
|
||
}
|
||
|
||
// A hostile title is escaped, not executed: titles come from the database and
|
||
// from third-party pages, so they are attacker-controlled.
|
||
func TestSeriesListEscapesHostileTitles(t *testing.T) {
|
||
st, dsn := newTestStoreURL(t)
|
||
db, err := sql.Open("pgx", dsn)
|
||
if err != nil {
|
||
t.Fatalf("open %s: %v", dsn, err)
|
||
}
|
||
defer db.Close()
|
||
if _, err := db.Exec(`
|
||
INSERT INTO series (site, series_id, title, series_url, cover_address, latest_checked_at)
|
||
VALUES ('asura', 'xss', '<script>alert(1)</script>', 'u', 'c', 9000)`); err != nil {
|
||
t.Fatalf("seed hostile title: %v", err)
|
||
}
|
||
srv := newRouter(st, testConfig(), nil)
|
||
|
||
body := adminSeriesPage(t, srv, st, "")
|
||
if strings.Contains(body, "<script>alert") {
|
||
t.Errorf("hostile title rendered unescaped:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, "<script>") {
|
||
t.Errorf("hostile title is not escaped:\n%s", body)
|
||
}
|
||
}
|
||
|
||
// Rows are the two-line form, banded by class (never nth-of-type), the site
|
||
// cell colours by class rather than inline style, chips cap at two plus a +N
|
||
// tail, and no ember token appears anywhere on the page.
|
||
func TestSeriesListRowShape(t *testing.T) {
|
||
st, dsn := newTestStoreURL(t)
|
||
db, err := sql.Open("pgx", dsn)
|
||
if err != nil {
|
||
t.Fatalf("open %s: %v", dsn, err)
|
||
}
|
||
defer db.Close()
|
||
// An orphan with no URL and no cover: three chips, capped to two plus a
|
||
// tail. The two clean rows sit on either side for the banding.
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:broken", checkedAt: 0, bookmarks: 0})
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:mid", url: "u", cover: "c", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||
srv := newRouter(st, testConfig(), nil)
|
||
|
||
body := adminSeriesPage(t, srv, st, "")
|
||
// Order is least-recently-checked first, then (site, series_id): broken
|
||
// (never checked), mid (stale), fine (fresh). The middle row carries the
|
||
// band class; the marked row the attention class.
|
||
if !(strings.Index(body, "Title of asura:broken") < strings.Index(body, "Title of asura:mid") &&
|
||
strings.Index(body, "Title of asura:mid") < strings.Index(body, "Title of asura:fine")) {
|
||
t.Errorf("rows are not in checked order:\n%s", body)
|
||
}
|
||
if strings.Count(body, `class="trow"`) != 1 {
|
||
t.Errorf("expected exactly one unmarked, unbanded row:\n%s", body)
|
||
}
|
||
if strings.Count(body, `class="trow attention"`) != 1 {
|
||
t.Errorf("expected exactly one attention row:\n%s", body)
|
||
}
|
||
if strings.Count(body, `class="trow attention band"`) != 1 {
|
||
t.Errorf("expected exactly one attention band row:\n%s", body)
|
||
}
|
||
// The orphan's chips cap at two plus the +N tail.
|
||
if !strings.Contains(body, `<span class="mark">no URL</span><span class="mark">no cover</span><span class="mark mark-faint">+1</span>`) {
|
||
t.Errorf("chips do not cap at two plus a tail:\n%s", body)
|
||
}
|
||
// The site cell is a class, never the design's inline style.
|
||
if strings.Contains(body, `style="color:var(--`) {
|
||
t.Errorf("a site cell carries an inline style:\n%s", body)
|
||
}
|
||
if !strings.Contains(body, `class="c-site site-asura"`) {
|
||
t.Errorf("the site cell lacks its site class:\n%s", body)
|
||
}
|
||
// The action cell is present and empty for the sibling ticket, and no
|
||
// Remove control or confirm row renders in this batch.
|
||
if !strings.Contains(body, `<span class="c-act"></span>`) {
|
||
t.Errorf("the action cell is not present and empty:\n%s", body)
|
||
}
|
||
if strings.Contains(body, "Remove") || strings.Contains(body, "confirm-row") {
|
||
t.Errorf("a Remove control or confirm row renders in this batch:\n%s", body)
|
||
}
|
||
// No ember: the new-chapter signal stays off the admin surface. Scoped to
|
||
// the page content — the shell's brand mark legitimately wears the ember
|
||
// flame on every page, admin or not.
|
||
mainStart := strings.Index(body, `<main class="page admin-page">`)
|
||
mainEnd := strings.Index(body, `</main>`)
|
||
if mainStart < 0 || mainEnd < 0 || mainStart > mainEnd {
|
||
t.Fatalf("no page content region to check:\n%s", body)
|
||
}
|
||
if strings.Contains(body[mainStart:mainEnd], "--ember") {
|
||
t.Errorf("the page content carries an ember token:\n%s", body)
|
||
}
|
||
}
|
||
|
||
// The per-Series page renders every Series-level fact the admin read model
|
||
// holds for the key the list row already shows: title, the composite key with
|
||
// Site and kind, the Latest Chapter, the check age and the anonymous Reader
|
||
// count. No Reader identity or progress may appear anywhere in the response.
|
||
func TestAdminSeriesDetailRendersFacts(t *testing.T) {
|
||
router, st := newWebTestServer(t, testConfig())
|
||
// Two Readers hold the same Series, so the anonymous count is 2.
|
||
seed(t, st, store.Bookmark{
|
||
Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby",
|
||
Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby",
|
||
Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45),
|
||
})
|
||
other, err := st.EnsureReader("reader-two", sha256.Sum256([]byte("reader-two-hash")))
|
||
if err != nil {
|
||
t.Fatalf("EnsureReader: %v", err)
|
||
}
|
||
if _, err := st.Upsert(other, store.Bookmark{
|
||
Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby",
|
||
Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby",
|
||
Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45),
|
||
}); err != nil {
|
||
t.Fatalf("Upsert second reader: %v", err)
|
||
}
|
||
if err := st.MarkLatestChecked("kagane", "sp-baby", time.Now().Add(-2*time.Hour).UnixMilli()); err != nil {
|
||
t.Fatalf("MarkLatestChecked: %v", err)
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/admin/series/kagane:sp-baby", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("GET /admin/series/kagane:sp-baby status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
for _, want := range []string{
|
||
"SP Baby",
|
||
"kagane:sp-baby · kagane · manga",
|
||
"ch 45",
|
||
"checked 2h0m",
|
||
"2 readers",
|
||
} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("series detail lacks %q:\n%s", want, body)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A Series the poller has never read renders the never-read state — "ch —" and
|
||
// "checked never" — rather than a confident zero, and a Series no Reader holds
|
||
// renders its count as "0 readers" rather than as a blank.
|
||
func TestAdminSeriesDetailNeverReadOrphanState(t *testing.T) {
|
||
router, st := newWebTestServer(t, testConfig())
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:abandoned", Site: "asura", SeriesID: "abandoned",
|
||
Title: "Abandoned", SeriesURL: "https://asurascans.com/series/abandoned",
|
||
Kind: "manga",
|
||
})
|
||
// Removing the only Bookmark orphans the Series: the row outlives it.
|
||
if err := st.Delete(st.OwnerID(), "asura:abandoned"); err != nil {
|
||
t.Fatalf("Delete: %v", err)
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:abandoned", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
for _, want := range []string{"ch —", "checked never", "0 readers"} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("never-read orphan detail lacks %q:\n%s", want, body)
|
||
}
|
||
}
|
||
if strings.Contains(body, "ch 0") {
|
||
t.Errorf("a never-read Series renders chapter 0:\n%s", body)
|
||
}
|
||
}
|
||
|
||
// The meta row renders each hygiene mark exactly when the underlying fact
|
||
// holds: a Series with no page to fetch, no cover, no Reader and a chapter a
|
||
// Sighting raised carries all four, and a whole one carries none.
|
||
func TestAdminSeriesDetailRendersMarks(t *testing.T) {
|
||
router, st := newWebTestServer(t, testConfig())
|
||
|
||
seed(t, st, store.Bookmark{
|
||
Key: "demonic:broken", Site: "demonic", SeriesID: "broken",
|
||
Title: "Broken", Kind: "manga",
|
||
})
|
||
if err := st.Delete(st.OwnerID(), "demonic:broken"); err != nil {
|
||
t.Fatalf("Delete: %v", err)
|
||
}
|
||
if err := st.RecordSighting(st.OwnerID(), "demonic", "broken", floatPtr(7), time.Now().UnixMilli()); err != nil {
|
||
t.Fatalf("RecordSighting: %v", err)
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/admin/series/demonic:broken", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rr.Code)
|
||
}
|
||
for _, want := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} {
|
||
if !strings.Contains(rr.Body.String(), want) {
|
||
t.Errorf("broken series detail lacks the %q mark:\n%s", want, rr.Body.String())
|
||
}
|
||
}
|
||
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo",
|
||
Kind: "manga", LatestChapter: "45", LatestChapterNum: floatPtr(45),
|
||
})
|
||
if err := st.SetSeriesCover("asura", "solo", "https://cdn.asurascans.com/covers/solo.webp",
|
||
[]byte("\x00webp-bytes"), "image/webp"); err != nil {
|
||
t.Fatalf("SetSeriesCover: %v", err)
|
||
}
|
||
|
||
req = httptest.NewRequest(http.MethodGet, "/admin/series/asura:solo", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr = httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
for _, mark := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} {
|
||
if strings.Contains(body, mark) {
|
||
t.Errorf("whole series detail carries the %q mark:\n%s", mark, body)
|
||
}
|
||
}
|
||
if !strings.Contains(body, `src="https://bookmarks.test/covers/`) {
|
||
t.Errorf("whole series detail does not render its stored cover:\n%s", body)
|
||
}
|
||
}
|
||
|
||
// A well-formed key naming no row is a 404, and so is a key with no ":",
|
||
// an empty Site or an empty SeriesID — the detail page never answers 500 for
|
||
// an address nobody can reach.
|
||
func TestAdminSeriesDetailUnknownKey404(t *testing.T) {
|
||
router, st := newWebTestServer(t, testConfig())
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo",
|
||
Kind: "manga",
|
||
})
|
||
|
||
for _, path := range []string{
|
||
"/admin/series/asura:no-such-row",
|
||
"/admin/series/no-colon",
|
||
"/admin/series/:empty-site",
|
||
"/admin/series/asura:",
|
||
"/admin/series/unknown-site:row",
|
||
} {
|
||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusNotFound {
|
||
t.Errorf("GET %s status = %d, want 404", path, rr.Code)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A Series past the first page of its Site's read (50 rows) must still
|
||
// render: the list shows it, so its detail link may not answer 404. The row
|
||
// lookup walks the window total, not just page 1.
|
||
func TestAdminSeriesDetailBeyondFirstPage(t *testing.T) {
|
||
router, st := newWebTestServer(t, testConfig())
|
||
for i := 0; i < 55; i++ {
|
||
seed(t, st, store.Bookmark{
|
||
Key: fmt.Sprintf("asura:s%03d", i), Site: "asura", SeriesID: fmt.Sprintf("s%03d", i),
|
||
Title: "Bulk", SeriesURL: "https://asurascans.com/series/bulk",
|
||
Kind: "manga",
|
||
})
|
||
}
|
||
// Rows order by (latest_checked_at, site, series_id), all zero stamps, so
|
||
// "zzz" lands on page 2 behind the fifty-five "s*" rows.
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:zzz", Site: "asura", SeriesID: "zzz",
|
||
Title: "Late", SeriesURL: "https://asurascans.com/series/zzz",
|
||
Kind: "manga",
|
||
})
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:zzz", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("GET /admin/series/asura:zzz status = %d, want 200 for a page-2 row", rr.Code)
|
||
}
|
||
if !strings.Contains(rr.Body.String(), "Late") {
|
||
t.Errorf("page-2 row did not render:\n%s", rr.Body.String())
|
||
}
|
||
}
|
||
|
||
// The title and the key line come from the database, so they must render
|
||
// escaped: a title that is markup stays markup in the response, never HTML.
|
||
func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) {
|
||
router, st := newWebTestServer(t, testConfig())
|
||
seed(t, st, store.Bookmark{
|
||
Key: "asura:evil", Site: "asura", SeriesID: "evil",
|
||
Title: `<script>alert("xss")</script>`, SeriesURL: "https://asurascans.com/series/evil",
|
||
Kind: "manga",
|
||
})
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:evil", nil)
|
||
req.AddCookie(sessionCookie(t, st))
|
||
rr := httptest.NewRecorder()
|
||
router.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", rr.Code)
|
||
}
|
||
body := rr.Body.String()
|
||
if !strings.Contains(body, "<script>") {
|
||
t.Errorf("title is not escaped:\n%s", body)
|
||
}
|
||
if strings.Contains(body, "<script>") {
|
||
t.Errorf("title rendered raw:\n%s", body)
|
||
}
|
||
}
|