feat(backend): Discord OAuth login with DB-backed sessions (#23)

The browser UI signs in with a Discord authorization code grant
(identify + guilds.members.read) instead of a shared password. Guild
membership is the gate; the owner's Discord ID is the only identity
allowed in while registration is closed. Sessions become rows in a
sessions table with opaque random ids — the cookie carries only the id,
looked up and expiry-checked per request — so deleting a row revokes a
session. HMAC cookie signing, its derived key, and WEB_PASSWORD are
gone, and no replacement signing secret is introduced (ADR-0002).

Discord's API base is configurable (DISCORD_API_BASE); the full flow is
tested through the real router against a local stub, including the
form-encoded token exchange Discord rejects if sent as JSON.
This commit is contained in:
2026-08-08 08:31:46 +07:00
parent 8cebb94b92
commit 13e8e73da7
19 changed files with 1272 additions and 357 deletions
+482 -85
View File
@@ -1,10 +1,14 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strconv"
"strings"
"testing"
@@ -13,13 +17,17 @@ import (
"bookmarkmanager/backend/internal/session"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/web"
_ "github.com/jackc/pgx/v5/stdlib"
)
const testPassword = "hunter2"
const testOwnerID = "owner-snowflake"
// webConfig returns a config whose web UI is usable: Discord identity is set,
// though the OAuth endpoints still need the stub URL from discordConfig.
func webConfig() Config {
cfg := testConfig()
cfg.WebPassword = testPassword
cfg.Discord.OwnerDiscordID = testOwnerID
return cfg
}
@@ -31,13 +39,165 @@ func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
return newRouter(st, cfg), st
}
// sessionCookie returns a cookie a handler will accept for cfg's API token.
func sessionCookie(t *testing.T, cfg Config) *http.Cookie {
// 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()
return &http.Cookie{
Name: session.CookieName,
Value: session.Sign(session.Key(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
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)
}
case strings.HasPrefix(r.URL.Path, "/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",
APIBBase: stubURL,
RedirectURI: "https://bm.example.com/auth/discord/callback",
OwnerDiscordID: testOwnerID,
}
}
// 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 := webConfig()
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
}
// readerCount pokes the readers table directly — the refusal contract is that
// nothing was created, which the store API would not show.
func readerCount(t *testing.T, url string) int {
t.Helper()
db, err := sql.Open("pgx", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
var n int
if err := db.QueryRow(`SELECT count(*) FROM readers`).Scan(&n); err != nil {
t.Fatalf("count readers: %v", err)
}
return n
}
func TestIndexWithoutSessionShowsLogin(t *testing.T) {
@@ -48,8 +208,8 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) {
if rr.Code != http.StatusOK {
t.Fatalf("GET / status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), `type="password"`) {
t.Fatal("GET / without a session did not render the password field")
if !strings.Contains(rr.Body.String(), "Continue with Discord") {
t.Fatal("GET / without a session did not render the Discord sign-in button")
}
}
@@ -65,7 +225,7 @@ func TestIndexWithSessionShowsList(t *testing.T) {
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie(t, cfg))
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
@@ -77,56 +237,265 @@ func TestIndexWithSessionShowsList(t *testing.T) {
}
}
func TestLoginSuccessSetsCookie(t *testing.T) {
srv, _ := newWebTestServer(t, webConfig())
req := httptest.NewRequest(http.MethodPost, "/login",
strings.NewReader(url.Values{"password": {testPassword}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
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("POST /login status = %d, want 303", rr.Code)
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("POST /login cookies = %+v, want one non-empty %s", cookies, session.CookieName)
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 single-guild endpoint.
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] != "/guilds/guild-1/members/owner-snowflake" {
t.Fatalf("member requests = %v, want the 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 TestLoginWrongPassword(t *testing.T) {
srv, _ := newWebTestServer(t, webConfig())
req := httptest.NewRequest(http.MethodPost, "/login",
strings.NewReader(url.Values{"password": {"wrong"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
func TestDiscordCallbackRejectsMissingState(t *testing.T) {
srv, _, stub := oauthWebTestServer(t)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("POST /login status = %d, want 401", rr.Code)
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 failed login set a cookie")
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 TestLoginRateLimited(t *testing.T) {
srv, _ := newWebTestServer(t, webConfig())
post := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/login",
strings.NewReader(url.Values{"password": {"wrong"}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
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 := webConfig()
cfg.Discord = discordConfig(srv.URL)
cfg.Discord.RequiredRole = tc.require
st, dbURL := newTestStoreURL(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 := readerCount(t, dbURL); 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 := webConfig()
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)
}
}
func TestDiscordLoginRefusesNonOwner(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.ownerID = "someone-elses-snowflake"
cfg := webConfig()
cfg.Discord = discordConfig(srv.URL)
router, st := newWebTestServer(t, 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 the library owner") {
t.Fatalf("refusal body = %q, want the owner-only explanation", rr.Body.String())
}
if len(rr.Result().Cookies()) != 0 {
t.Fatal("a refused sign-in set a cookie")
}
if sess, ok, _ := st.GetSession("anything", time.Now()); ok && sess.ID != "" {
t.Fatal("a refused sign-in created a session")
}
}
func TestDiscordLoginTokenEndpointDown(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.tokenStatus = http.StatusInternalServerError
cfg := webConfig()
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 := post().Code; code != http.StatusUnauthorized {
t.Fatalf("attempt %d status = %d, want 401", i+1, code)
if code := call().Code; code != http.StatusBadRequest {
t.Fatalf("attempt %d status = %d, want 400", i+1, code)
}
}
rr := post()
rr := call()
if rr.Code != http.StatusTooManyRequests {
t.Fatalf("attempt %d status = %d, want 429", session.MaxFailures+1, rr.Code)
}
@@ -137,11 +506,13 @@ func TestLoginRateLimited(t *testing.T) {
}
}
func TestLogoutClearsCookie(t *testing.T) {
func TestLogoutDeletesSession(t *testing.T) {
cfg := webConfig()
srv, _ := newWebTestServer(t, cfg)
srv, st := newWebTestServer(t, cfg)
cookie := sessionCookie(t, st)
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(sessionCookie(t, cfg))
req.AddCookie(cookie)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
@@ -152,26 +523,52 @@ func TestLogoutClearsCookie(t *testing.T) {
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 TestWebDisabledWhenNoPassword(t *testing.T) {
cfg := testConfig() // WebPassword empty
srv, _ := newWebTestServer(t, cfg)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
func TestExpiredSessionRejected(t *testing.T) {
cfg := webConfig()
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}
if rr.Code != http.StatusNotFound {
t.Fatalf("GET / with WEB_PASSWORD unset = %d, want 404", rr.Code)
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 := webConfig()
srv, _ := newWebTestServer(t, cfg)
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, cfg))
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
@@ -210,7 +607,7 @@ func seed(t *testing.T, st *store.Store, b store.Bookmark) store.Bookmark {
return stored
}
func uiRequest(t *testing.T, cfg Config, method, path string, form url.Values) *http.Request {
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 {
@@ -219,7 +616,7 @@ func uiRequest(t *testing.T, cfg Config, method, path string, form url.Values) *
req = httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
req.AddCookie(sessionCookie(t, cfg))
req.AddCookie(sessionCookie(t, st))
return req
}
@@ -252,7 +649,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
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)
}
@@ -274,7 +671,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
// Toggling again turns it back off.
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
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")
@@ -301,7 +698,7 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list", nil))
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodGet, "/ui/list", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
@@ -328,7 +725,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
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)
@@ -368,7 +765,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
// 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, cfg, http.MethodPost,
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)
@@ -403,7 +800,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
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, cfg, http.MethodPost,
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)
@@ -418,13 +815,13 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
func TestMutationsOnMissingKey(t *testing.T) {
cfg := webConfig()
srv, _ := newWebTestServer(t, cfg)
srv, st := newWebTestServer(t, cfg)
cases := []struct {
name string
req *http.Request
}{
{"favorite", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/favorite", nil)},
{"chapter", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/chapter", url.Values{"chapter": {"1"}})},
{"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) {
@@ -446,7 +843,7 @@ func TestUIDeleteRemovesRow(t *testing.T) {
})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodDelete, "/ui/bookmarks/asura:solo", nil))
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)
}
@@ -477,7 +874,7 @@ func TestUIListFavouritesTab(t *testing.T) {
})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=fav", nil))
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)
}
@@ -507,7 +904,7 @@ func TestUIListNewTab(t *testing.T) {
})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=new", nil))
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)
}
@@ -562,7 +959,7 @@ func TestTabsShowOnlyTheirBucket(t *testing.T) {
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, cfg))
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
@@ -586,10 +983,10 @@ func TestTabsShowOnlyTheirBucket(t *testing.T) {
// stripOf returns everything above the list, which is where the recent section
// renders.
func stripOf(t *testing.T, srv http.Handler, cfg Config, tab string) string {
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, cfg))
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
body := rr.Body.String()
@@ -616,7 +1013,7 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
t.Fatalf("seed %s: %v", caught.Key, err)
}
strip := stripOf(t, srv, cfg, "all")
strip := stripOf(t, srv, st, "all")
if !strings.Contains(strip, "ReadingOne") {
t.Fatal("strip dropped the series with an unread chapter")
}
@@ -626,7 +1023,7 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
}
}
for _, tab := range []string{"new", "fav", "archived", "finished"} {
if strings.Contains(stripOf(t, srv, cfg, tab), "ReadingOne") {
if strings.Contains(stripOf(t, srv, st, tab), "ReadingOne") {
t.Fatalf("tab %s rendered the strip", tab)
}
}
@@ -642,7 +1039,7 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
}
// 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, cfg, "all")
empty := stripOf(t, srv, st, "all")
if strings.Contains(empty, "recent-card") {
t.Fatal("strip rendered cards with no unread chapters anywhere")
}
@@ -666,18 +1063,18 @@ func TestRecentStripCapped(t *testing.T) {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != web.RecentCount {
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, cfg Config, key, status string) *httptest.ResponseRecorder {
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, cfg))
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
@@ -689,7 +1086,7 @@ func TestUIStatusSetsBucket(t *testing.T) {
seedStatusRows(t, st)
for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} {
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
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")
@@ -707,7 +1104,7 @@ func TestUIStatusRejectsUnknownValue(t *testing.T) {
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
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")
@@ -738,7 +1135,7 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
before, _, _ := st.Get(st.OwnerID(), "asura:reading")
time.Sleep(2 * time.Millisecond)
if rr := postStatus(t, srv, cfg, "asura:reading", store.StatusArchived); rr.Code != http.StatusOK {
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")
@@ -766,7 +1163,7 @@ func TestCardShowsStatusControls(t *testing.T) {
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, cfg))
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
@@ -791,7 +1188,7 @@ func TestAppRendersNewTabs(t *testing.T) {
seedStatusRows(t, st)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie(t, cfg))
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
@@ -813,12 +1210,12 @@ func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
})
before := stripOf(t, srv, cfg, "all")
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, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status",
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()
@@ -865,7 +1262,7 @@ func TestLibrariesAreDisjoint(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, tc.path, nil))
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodGet, tc.path, nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
@@ -890,7 +1287,7 @@ func TestKindlessRowShowsInMangaLibrary(t *testing.T) {
})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=all", nil))
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")
}
@@ -902,7 +1299,7 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) {
seedLibraries(t, st)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/?lib=novel&tab=all", nil))
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)
}
@@ -933,7 +1330,7 @@ func TestMangaPageKeepsUpdatedTab(t *testing.T) {
seedLibraries(t, st)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/?tab=all", nil))
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")
@@ -951,7 +1348,7 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) {
seedLibraries(t, st)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?lib=novel&tab=new", nil))
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)
}