package main import ( "crypto/sha256" "database/sql" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "reflect" "regexp" "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. The Lanes page // reads the pass log (issue #145), so a test seeds store rows rather than // standing in for a poller. func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) { t.Helper() st := newTestStore(t) return newRouter(st, cfg), 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) (http.Handler, *store.Store, *discordStub) { t.Helper() stub, srv := newDiscordStub(t) cfg := testConfig() cfg.Discord = discordConfig(srv.URL) router, st := newWebTestServer(t, cfg) 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) 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") } } // seedPass writes one durable pass row — the Lanes page's whole seam. // The page renders from the database with no poller running at all (issue // #145), so a test seeds rows instead of constructing a fake reporter. func seedPass(t *testing.T, st *store.Store, p store.LanePass) { t.Helper() if err := st.RecordLanePass(p, -1); err != nil { t.Fatalf("seed pass %s: %v", p.Site, err) } } // lanesConfig returns a config with latest-chapter polling switched on, so // the Lanes page's statusline speaks about the browser rather than about // polling being off. func lanesConfig() Config { cfg := testConfig() cfg.LatestPoll.Enabled = true return cfg } // lanesBody fetches the Lanes fragment as the owner and returns the body. func lanesBody(t *testing.T, router http.Handler, st *store.Store) string { t.Helper() 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) } return rr.Body.String() } // 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) path = strings.Replace(path, "{site}", "asura", 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 pass log says, and marks the Lanes that // need attention: 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. Rows come from seeded database rows — no poller runs anywhere. func TestAdminPageShowsLaneStatus(t *testing.T) { cfg := lanesConfig() cfg.BrowserWSURL = "ws://browser:9222" router, st := newWebTestServer(t, cfg) now := time.Now() seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-90 * time.Second).UnixMilli(), Due: 12, Checked: 12, GapMS: 40_000}) seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 3, Checked: 3, GapMS: 60_000}) seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Due: 400, Checked: 400, GapMS: 8_000}) seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 7, Checked: 0, GapMS: 60_000}) body := lanesBody(t, router, st) for _, want := range []string{"asura", "kagane", "demonic", "comix", "40s", "ran 1m30s ago", "not checking", "none observed", "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 // pass row records it with the asleep skip, and the page must render its // sleeping sentence with no stall mark and no attention. func TestAsleepBrowserLaneIsNotMarkedStalled(t *testing.T) { cfg := lanesConfig() cfg.BrowserWSURL = "ws://browser:9222" router, st := newWebTestServer(t, cfg) seedPass(t, st, store.LanePass{ Site: "kagane", RanAt: time.Now().UnixMilli(), Skip: latest.SkipAsleep, Due: 1, Checked: 0, GapMS: 10_000, }) body := lanesBody(t, router, st) 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="trow 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 that has never gathered figures draws "—" for the // gap rather than stating a zero it did not measure. func TestLaneStatusOmitsUnknownGap(t *testing.T) { cfg := lanesConfig() cfg.BrowserWSURL = "ws://browser:9222" st, dsn := newTestStoreURL(t) router := newRouter(st, cfg) now := time.Now() oldNow := now.Add(-time.Minute).UnixMilli() seedPass(t, st, store.LanePass{Site: "comix", RanAt: oldNow, Skip: latest.SkipRefusing}) // Refusal expiry lives on the Lane (poll_lanes), not the pass row, so it // must be seeded there for the backs-off-until clause to render. 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 poll_lanes (site, refuse_until) VALUES ($1, $2)`, "comix", now.Add(10*time.Minute).UnixMilli()); err != nil { t.Fatalf("seed lane refusal: %v", err) } body := lanesBody(t, router, st) if strings.Contains(body, `>0s<`) { t.Errorf("a Lane with no pace yet states a zero gap:\n%s", body) } if !strings.Contains(body, "refusing · backs off until") { t.Errorf("a refusing Lane is not marked with its backoff time:\n%s", body) } } // Polling switched off and a browser not configured are different facts, and // the page must not blame the sidecar when nothing polls. Neither is a poller // running — the Lanes page answers entirely from config and the pass log. func TestAdminPageWithoutAPollerSaysSo(t *testing.T) { for _, tc := range []struct { name string cfg Config want, unwant string }{ {"polling switched off", testConfig(), `Polling: off`, "not configured"}, {"browser not configured", lanesConfig(), "not configured", "Polling is switched off"}, } { t.Run(tc.name, func(t *testing.T) { router, st := newWebTestServer(t, tc.cfg) body := lanesBody(t, router, st) 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) } }) } } // Restart survival is the point of the durable lane state: rows seeded into // poll_passes render with no poller running anywhere, complete thirty seconds // after a deploy. This is the test that proves the in-memory path is gone. func TestLanesRenderFromSeededRowsAfterRestart(t *testing.T) { router, st := newWebTestServer(t, lanesConfig()) now := time.Now() // A pass a minute ago, another three hours ago: the latest per Site wins. seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-3 * time.Hour).UnixMilli(), Due: 1, Checked: 1, GapMS: 10_000}) seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 5, Checked: 5, GapMS: 20_000}) body := lanesBody(t, router, st) if !strings.Contains(body, `class="c-site">asura`) { t.Fatalf("asura row missing from a restart-read database:\n%s", body) } // The fragment carries its own single timer and answers the swap it asked // for, so the page keeps refreshing against the database. if !strings.Contains(body, `hx-get="/ui/admin/lanes"`) || !strings.Contains(body, `hx-trigger="every 30s"`) { t.Errorf("Lanes fragment lost its self-refresh:\n%s", body) } } // A skip reason is the whole difference between a Lane resting and a Lane // stuck: a skipped pass says why it declined, and the one true stall — empty // skip with Series due and none read — is the only thing that draws the fault. func TestSkipReasonIsNotAStall(t *testing.T) { router, st := newWebTestServer(t, lanesConfig()) now := time.Now() // Asleep: due but none checked, with a skip that says why — no stall, no // attention. seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipAsleep, Due: 2, Checked: 0, GapMS: 10_000}) // The true stall: reached the loop, Series waiting, none read. seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 3, Checked: 0, GapMS: 20_000}) body := lanesBody(t, router, st) if !strings.Contains(body, "browser asleep") { t.Errorf("the asleep row does not say why it declined:\n%s", body) } if !strings.Contains(body, "not checking") { t.Errorf("the true stall is not rendered as a fault:\n%s", body) } // Exactly the stall row wears attention; the asleep row never does. if strings.Count(body, `class="trow attention"`) != 1 { t.Errorf("attention is on %d rows, want exactly the stall:\n%s", strings.Count(body, `class="trow attention"`), body) } } // The six outcome counts render named — the page never prints the word // "failures" — in the taxonomy's fixed order, not found beside the word it // split out of. A Site with none observed says so rather than drawing a // blank cell, and a zero not_found renders no chip of its own. func TestNamedOutcomeChips(t *testing.T) { router, st := newWebTestServer(t, lanesConfig()) now := time.Now() seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Refused: 5, Unreachable: 3, NoChapter: 2, Unfetchable: 4, NotFound: 6, Errors: 1}) seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Unreachable: 7, Unfetchable: 8}) seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli()}) body := lanesBody(t, router, st) for _, want := range []string{"refused 5", "unreachable 3", "no chapter 2", "unfetchable 4", "not found 6", "errors 1"} { if !strings.Contains(body, want) { t.Errorf("lane chips lack %q:\n%s", want, body) } } wantSeq := `refused 5 · unreachable 3 · no chapter 2 · unfetchable 4 · not found 6 · errors 1` if !strings.Contains(body, wantSeq) { t.Errorf("chips do not render in the fixed order (… unfetchable, not found, errors …):\n%s", body) } // demonic has outcomes but no not_found: exactly one "not found" in the // whole fragment — asura's — so a zero count renders none. if got := strings.Count(body, "not found"); got != 1 { t.Errorf("the word \"not found\" appears %d times, want exactly the one seeded chip", got) } if strings.Contains(body, "failures") { t.Errorf("the page prints the forbidden word \"failures\":\n%s", body) } // comix has no outcomes in the window: it must say none observed, and the // phrase must not be blank. if !strings.Contains(body, "none observed") { t.Errorf("a Site with no outcomes does not say none observed:\n%s", body) } } // Browser configuration is a deployment fact and reachability is derived from // the latest browser-Site passes inside the refusal backoff — no poller in any // of the three. func TestBrowserConfigAndReachabilityDerived(t *testing.T) { now := time.Now() t.Run("not configured", func(t *testing.T) { router, st := newWebTestServer(t, lanesConfig()) seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipNoFetcher}) body := lanesBody(t, router, st) if !strings.Contains(body, `mark-faint">not configured`) || strings.Contains(body, "unreachable") { t.Errorf("unset BROWSER_WS_URL must read as not configured:\n%s", body) } }) t.Run("unreachable from recent sidecar-down", func(t *testing.T) { cfg := lanesConfig() cfg.BrowserWSURL = "ws://browser:9222" router, st := newWebTestServer(t, cfg) seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Skip: latest.SkipSidecarDown}) body := lanesBody(t, router, st) if !strings.Contains(body, `bad">unreachable`) { t.Errorf("recent sidecar-down passes must read as unreachable:\n%s", body) } }) t.Run("reachable from clean passes", func(t *testing.T) { cfg := lanesConfig() cfg.BrowserWSURL = "ws://browser:9222" router, st := newWebTestServer(t, cfg) seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Checked: 3}) body := lanesBody(t, router, st) if !strings.Contains(body, `mark-strong">reachable`) { t.Errorf("clean browser passes must read as reachable:\n%s", body) } }) t.Run("sidecar-down outside the backoff is reachable again", func(t *testing.T) { cfg := lanesConfig() cfg.BrowserWSURL = "ws://browser:9222" router, st := newWebTestServer(t, cfg) seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-30 * time.Minute).UnixMilli(), Skip: latest.SkipSidecarDown}) body := lanesBody(t, router, st) if !strings.Contains(body, `mark-strong">reachable`) { t.Errorf("a sidecar-down older than the backoff must read as reachable:\n%s", body) } }) } // Pressing Pause writes a future expiry at the offered length and answers // with the freshly rendered Lanes block, so the row the owner just pressed // reads as paused with its remaining time and offers Resume — with no poller // having run at all. The pause is a fact about the Site, never a command to // a process (issue #147). func TestLanePauseRoundTrip(t *testing.T) { router, st := newWebTestServer(t, lanesConfig()) // A stall-shaped pass — due but none checked, no skip — so the test // proves the pause renders over it as paused, never as the one true // stall. seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli(), Due: 3, Checked: 0, GapMS: 10_000}) for _, tc := range []struct{ duration, phrase string }{ {"1h", "paused · resumes in 1h"}, {"6h", "paused · resumes in 6h"}, {"24h", "paused · resumes in 24h"}, } { t.Run(tc.duration, func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/pause", strings.NewReader("duration="+tc.duration)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(sessionCookie(t, st)) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("POST pause status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) } body := rr.Body.String() if !strings.Contains(body, `id="lanes"`) { t.Errorf("pause response is not the Lanes block:\n%s", body) } if !strings.Contains(body, tc.phrase) { t.Errorf("pause response does not render %q:\n%s", tc.phrase, body) } if !strings.Contains(body, `hx-post="/admin/lanes/asura/resume"`) { t.Errorf("pause response does not offer Resume for asura:\n%s", body) } if strings.Contains(body, `class="trow attention"`) { t.Errorf("a paused Lane is marked for attention:\n%s", body) } if strings.Contains(body, "not checking") { t.Errorf("a paused Lane reads as the one true stall:\n%s", body) } // The expiry is a future fact about the Site, at the offered length. paused, _, err := st.LaneGates("asura") if err != nil { t.Fatalf("LaneGates: %v", err) } d, _ := time.ParseDuration(tc.duration) want := time.Now().Add(d).UnixMilli() if paused < want-time.Minute.Milliseconds() || paused > want+time.Minute.Milliseconds() { t.Errorf("pause expiry = %d, want now+%s (%d, within a minute)", paused, tc.duration, want) } }) } } // A missing duration and any value outside the offered set are refused with // 400 and nothing is written: a permissive parser would turn the offered set // into "anything Go can read", and an unoffered pause is a silent outage the // owner left behind (issue #147). func TestLanePauseRejectsBadDurations(t *testing.T) { router, st := newWebTestServer(t, lanesConfig()) seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli()}) for _, tc := range []struct{ name, body string }{ {"missing", ""}, {"empty value", "duration="}, {"unoffered", "duration=2h"}, {"zero", "duration=0h"}, {"absurd", "duration=999h"}, {"not a duration", "duration=six"}, } { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/pause", strings.NewReader(tc.body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(sessionCookie(t, st)) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("pause body %q status = %d, want 400", tc.body, rr.Code) } }) } paused, _, err := st.LaneGates("asura") if err != nil { t.Fatalf("LaneGates: %v", err) } if paused != 0 { t.Errorf("a rejected pause still wrote expiry %d", paused) } } // The Site in the path is client-supplied, so it is checked against the // registry before the store sees it: an unknown Site is a 400 on both action // routes, not a write (issue #147). func TestLaneActionsRejectUnknownSite(t *testing.T) { router, st := newWebTestServer(t, lanesConfig()) for _, tc := range []struct{ path, body string }{ {"/admin/lanes/notasite/pause", "duration=6h"}, {"/admin/lanes/notasite/resume", ""}, } { req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(sessionCookie(t, st)) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("POST %s status = %d, want 400", tc.path, rr.Code) } } } // Pressing Resume zeroes the pause and answers with the freshly rendered // block: the same slot now offers the duration select and Pause, and the // paused phrase is gone. The queue half of resume lives in the poller tests // (issue #147). func TestLaneResumeRoundTrip(t *testing.T) { router, st := newWebTestServer(t, lanesConfig()) seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli(), Due: 2, Checked: 2, GapMS: 10_000}) if err := st.PauseLane("asura", time.Now().Add(6*time.Hour).UnixMilli()); err != nil { t.Fatalf("PauseLane: %v", err) } req := httptest.NewRequest(http.MethodPost, "/admin/lanes/asura/resume", nil) req.AddCookie(sessionCookie(t, st)) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("POST resume status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) } body := rr.Body.String() if !strings.Contains(body, `hx-post="/admin/lanes/asura/pause"`) || !strings.Contains(body, ">Pause") { t.Errorf("resume response does not offer Pause again:\n%s", body) } if strings.Contains(body, "Resume") || strings.Contains(body, "paused") { t.Errorf("resume response still reads as paused:\n%s", body) } paused, _, err := st.LaneGates("asura") if err != nil { t.Fatalf("LaneGates: %v", err) } if paused != 0 { t.Errorf("resume left pause expiry %d, want 0", paused) } } // Form bodies on both action routes are capped the way the API path caps // them: an oversized body is a 400, not a memory grant, and nothing is // written (issue #147). func TestLaneActionsCapBody(t *testing.T) { router, st := newWebTestServer(t, lanesConfig()) seedPass(t, st, store.LanePass{Site: "asura", RanAt: time.Now().UnixMilli()}) big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap for _, path := range []string{"/admin/lanes/asura/pause", "/admin/lanes/asura/resume"} { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(big)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(sessionCookie(t, st)) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("oversized body on %s status = %d, want 400", path, rr.Code) } } paused, _, err := st.LaneGates("asura") if err != nil { t.Fatalf("LaneGates: %v", err) } if paused != 0 { t.Errorf("an oversized body still paused the Lane (expiry %d)", paused) } } // A pause renders as paused, never as stalled: the owner's own act must not // be reported back as a fault. The stamp lives on poll_lanes and the pass // rows join it, so a pause seeded straight into the store — a restart, no // poller anywhere — renders its patina phrase with no attention flag and no // stall mark (issue #147). func TestPausedLaneRendersAsPausedNotStalled(t *testing.T) { router, st := newWebTestServer(t, lanesConfig()) now := time.Now() seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 3, Checked: 0, GapMS: 10_000}) if err := st.PauseLane("asura", now.Add(6*time.Hour).UnixMilli()); err != nil { t.Fatalf("PauseLane: %v", err) } body := lanesBody(t, router, st) if !strings.Contains(body, `class="ok">paused · resumes in 6h<`) { t.Errorf("a paused Lane does not render the patina phrase:\n%s", body) } if strings.Contains(body, `class="trow attention"`) { t.Errorf("a paused Lane is marked for attention:\n%s", body) } if strings.Contains(body, "not checking") { t.Errorf("a paused Lane reads as the one true stall:\n%s", body) } if strings.Contains(body, `class="bad"`) { t.Errorf("a paused Lane wears the fault accent:\n%s", 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()) 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-']) rather than a bare CSS id-selector (#card-). // // 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) != 4 { t.Fatalf("body has %d occurrences of %s, want 4 (favorite, archive, 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)}, } 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"}}, {"new", []string{"ReadingOne"}, []string{"ArchivedOne"}}, {"fav", []string{"ReadingOne"}, []string{"ArchivedOne"}}, {"archived", []string{"ArchivedOne"}, []string{"ReadingOne"}}, } 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"} { if strings.Contains(strip, unwanted) { t.Fatalf("strip included %s", unwanted) } } for _, tab := range []string{"new", "fav", "archived"} { 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.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, not restored, and there is no // finish control any more (issue #157). {"all", []string{`hx-vals='{"status":"archived"}'`}, []string{`hx-vals='{"status":"finished"}'`}}, // An archived one can come back. {"archived", []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"`} { if !strings.Contains(rr.Body.String(), want) { t.Fatalf("app page missing %s", want) } } if strings.Contains(rr.Body.String(), `href="/?tab=finished"`) { t.Fatal("app page still offers a Finished tab") } } // 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", } { 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()) 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, `