Open registration to guild members (#27) (#36)

Closes #27.

Guild membership is now the whole gate. `discordCallback` checks membership
(and `DISCORD_REQUIRED_ROLE` when set), then `Store.EnsureReader` creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before `EnsureReader`, so a turned-away sign-in leaves no row
behind. `OWNER_DISCORD_ID` still seeds the owner, but only as the
administrator — it no longer gates login.

The cutover grace path goes with it: `API_TOKEN`, `API_TOKEN_GRACE_UNTIL` and
the legacy branch in `httpmw.ResolveReader` are deleted, so a credential
authenticates exactly one Reader or nothing. `userscript.Handler` drops its
re-derivation too — the resolved path segment is already the credential.

New surfaces: an empty library offers both install links (behind the
tab-specific empty states, so "No favourites yet" still wins), and the owner
alone gets a Readers panel with `POST /readers/{id}/revoke`. The owner's own
row is not revocable — 404, not a self-logout.

Isolation is asserted from both directions for read, modify and delete, and
the shared-series invariant is pinned: two Readers on one series produce one
series row, two independent progresses, one poll per due cycle, and one
Reader's delete leaves the other's bookmark and the poll intact.

Verified: `go test ./...` green; live smoke against a throwaway Postgres —
empty-library state in both colour branches, roster rendering, a real revoke
through the panel (target 401s next request, owner untouched), owner
self-revoke refused 404, per-Reader `/u/<cred>` and bearer auth both 200 with
404 for an unknown credential.

Reviewed-on: #36
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #36.
This commit is contained in:
2026-08-08 20:23:17 +07:00
committed by sulthan
parent c2b47eb05b
commit 2ef769d421
25 changed files with 850 additions and 367 deletions
+20 -12
View File
@@ -21,9 +21,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
container per test binary (`TestMain` -> `pgtest.Main`) and hands each test
its own database (`pgtest.URL(t)`). A package whose tests touch the store
must have that `TestMain`.
- **Single-owner store, four tables.** `readers` is keyed by Discord user ID
- **Reader-owned store, four tables.** `readers` is keyed by Discord user ID
and carries the SHA-256 of the Reader's userscript credential plus a
`token_epoch` (issue #24). Credentials are derived, never stored: `token.Token(TOKEN_KEY, discord_id, epoch)` (HMAC, `internal/token`), and only its SHA-256 sits in `readers.token_sha256`, so install URLs can be rebuilt after any restart while a database leak yields nothing but hashes. The seed creates exactly one row at startup; its epoch-0 hash is refreshed on every start **only while the row has never been rotated**, so a restart can never resurrect a rotated-away credential. Rotation is `Store.RotateToken` (epoch bump + hash rewrite in one transaction), driven by the web UI.
`token_epoch` (issue #24). Credentials are derived, never stored: `token.Token(TOKEN_KEY, discord_id, epoch)` (HMAC, `internal/token`), and only its SHA-256 sits in `readers.token_sha256`, so install URLs can be rebuilt after any restart while a database leak yields nothing but hashes. The seed creates the **owner** row at startup; its epoch-0 hash is refreshed on every start **only while the row has never been rotated**, so a restart can never resurrect a rotated-away credential. Every other row is created by that Reader's own first login (`Store.EnsureReader`, idempotent on `discord_id`, and it never rewrites an existing row's hash). Rotation is `Store.RotateToken` (epoch bump + hash rewrite in one transaction), driven by the web UI.
`series` keyed `(site, series_id)`
(`asura`|`demonic`|`comix`|`kagane`|`novelfull`|`lightnovelworld`) owns the
shared facts — title, cover, canonical URL, `kind` (`manga`|`novel`),
@@ -32,10 +32,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`updated_at`. A bookmark is keyed `(reader_id, site, series_id)` — no
surrogate id; the wire `key` is derived as `site:series_id` on read — and
every store read/write is scoped to the reader it names. Auth resolves the
acting Reader from the presented credential (`httpmw.Auth`), and the
reader id travels in the request context; the retired global `API_TOKEN`
additionally resolves to the owner until `API_TOKEN_GRACE_UNTIL`, with
every such acceptance logged. Sync **last-write-wins**; the wire format
acting Reader from the presented credential (`httpmw.Auth`) and nothing
else — there is no unauthenticated-by-Reader route and no global token; the
reader id travels in the request context. Sync **last-write-wins**; the wire format
stays flat (ADR-0004). `Store.Upsert` decomposes one flat body across two
tables and enforces the ownership rule: client `title`/`series_url`/`cover`
are written only when the series row is new (ADR-0003).
@@ -49,8 +48,15 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`internal/` tree, not just `*.go`. Sessions are rows in the `sessions`
table: the cookie carries only an opaque id, looked up (and expiry-
checked) on every request, and deleting the row revokes the session.
The owner's Discord ID is the only identity that can sign in while
registration is closed. UI mutations read-modify-write
Guild membership *is* registration (issue #27): `discordCallback` gates on
membership (and `DISCORD_REQUIRED_ROLE` when set) and then calls
`Store.EnsureReader`, so a refusal creates nothing and a returning Reader
reuses their row. The owner is the only Reader with administrative reach:
`POST /readers/{id}/revoke` (404 for anyone else) drops that Reader's
sessions, and the `readers` panel renders only on the owner's page.
A Reader with no bookmarks at all sees `listView.Fresh`, whose empty state
offers both install links instead of describing a filter.
UI mutations read-modify-write
through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one
place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`.
**Design-tool caveat:** templates link `/static/style.css` root-absolutely
@@ -104,10 +110,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
wipe bucket on every PUT from client that predates column. See
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
- **Config via env:** `TOKEN_KEY` (derives every Reader's userscript credential;
required), `API_TOKEN` + `API_TOKEN_GRACE_UNTIL` (retired global credential
and the moment it stops resolving to the owner — both removed after the
cutover window, enforced in code), `OWNER_DISCORD_ID` (seeds the owner Reader;
required), `ALLOWED_ORIGINS` (comma list),
required), `OWNER_DISCORD_ID` (seeds the owner Reader — the administrator and
the owner of every pre-registration bookmark; required),
`ALLOWED_ORIGINS` (comma list),
`DATABASE_URL` (Postgres connection URL, required — no default),
`PORT` (default `8080`), `DISCORD_CLIENT_ID`/`_CLIENT_SECRET`/`_GUILD_ID`/
`_REDIRECT_URI` (required; Discord OAuth for the browser UI),
@@ -132,3 +137,6 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`POST /rotate-token` (atomic epoch bump + hash
rewrite; invalidates every installed copy, so the panel warns to reinstall
on all devices).
Owner-only `POST /readers/{id}/revoke` (drops one Reader's session rows and
re-renders the `readers` panel; 404 for any non-owner) is the only route that
reaches across Readers.
+5 -7
View File
@@ -17,8 +17,6 @@ import (
"bookmarkmanager/backend/internal/token"
)
const testToken = "s3cret-token"
// testTokenKey derives every test Reader's credential; it must match the key
// newTestStoreURL seeds the owner with, or derived credentials authenticate
// nothing.
@@ -30,9 +28,7 @@ const testDiscordID = "test-owner"
func testConfig() Config {
return Config{
Token: testToken,
TokenKey: testTokenKey,
GraceUntil: time.Now().Add(24 * time.Hour),
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
Port: "8080",
}
@@ -72,8 +68,10 @@ func newTestStoreURL(t *testing.T) (*store.Store, string) {
return s, url
}
// auth authenticates a request as the owner Reader, whose derived credential
// is the only thing the API accepts.
func auth(req *http.Request) *http.Request {
req.Header.Set("Authorization", "Bearer "+testToken)
req.Header.Set("Authorization", "Bearer "+ownerCredential())
return req
}
@@ -134,7 +132,7 @@ func TestAuthRequired(t *testing.T) {
}{
{"no header", ""},
{"bad token", "Bearer wrong"},
{"not bearer", "Basic " + testToken},
{"not bearer", "Basic " + ownerCredential()},
{"empty bearer", "Bearer "},
}
for _, tc := range cases {
@@ -604,7 +602,7 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
"series_url":"https://asurascans.com/comics/x",
"last_chapter":"Chapter 5","last_chapter_num":5}`
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+testToken)
req.Header.Set("Authorization", "Bearer "+ownerCredential())
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
+9 -21
View File
@@ -3,11 +3,9 @@ package httpmw
import (
"compress/gzip"
"context"
"crypto/subtle"
"log"
"net/http"
"strings"
"time"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
@@ -26,39 +24,29 @@ func ReaderID(r *http.Request) int64 { return r.Context().Value(readerCtxKey).(i
// ResolveReader maps a presented credential to a Reader. The credential is
// hashed and matched against readers.token_sha256 — an equality on 32-byte
// values, never a comparison of the credential itself — and, during the
// cutover window, the retired global token resolves to the owner. Every
// legacy acceptance is logged so the window can be confirmed empty before
// the token is removed. The same resolution backs the API bearer header and
// the userscript download path, so the window covers both.
func ResolveReader(s *store.Store, legacy string, graceUntil time.Time, cred string) (int64, bool) {
if readerID, ok, err := s.ReaderIDForTokenHash(token.Hash(cred)); err != nil {
// values, never a comparison of the credential itself. The same resolution
// backs the API bearer header and the userscript download path, so a Reader
// has exactly one credential with one blast radius.
func ResolveReader(s *store.Store, cred string) (int64, bool) {
readerID, ok, err := s.ReaderIDForTokenHash(token.Hash(cred))
if err != nil {
log.Printf("auth: reader lookup: %v", err)
return 0, false
} else if ok {
return readerID, true
}
if legacy != "" && time.Now().Before(graceUntil) &&
subtle.ConstantTimeCompare([]byte(cred), []byte(legacy)) == 1 {
log.Printf("auth: retired global token accepted for owner reader %d (grace until %s)",
s.OwnerID(), graceUntil.Format(time.RFC3339))
return s.OwnerID(), true
}
return 0, false
return readerID, ok
}
// Auth guards a handler with a per-Reader bearer credential. The acting
// Reader travels in the request context, so a handler scopes every store call
// to exactly the Reader that authenticated.
func Auth(s *store.Store, legacy string, graceUntil time.Time, next http.Handler) http.Handler {
func Auth(s *store.Store, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, bearerPrefix) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
readerID, ok := ResolveReader(s, legacy, graceUntil, strings.TrimPrefix(h, bearerPrefix))
readerID, ok := ResolveReader(s, strings.TrimPrefix(h, bearerPrefix))
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
+11
View File
@@ -2,6 +2,7 @@ package store
import (
"database/sql"
"fmt"
"time"
)
@@ -67,3 +68,13 @@ func (s *Store) DeleteSession(id string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = $1`, id)
return err
}
// DeleteReaderSessions revokes every session one Reader holds — the owner's
// remedy when a Reader's browser must be logged out everywhere at once. The
// next request carrying any of those cookies finds no row and is rejected.
func (s *Store) DeleteReaderSessions(readerID int64) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE reader_id = $1`, readerID); err != nil {
return fmt.Errorf("delete sessions for reader %d: %w", readerID, err)
}
return nil
}
+70 -10
View File
@@ -169,10 +169,11 @@ const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover,
const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at`
// Owner is the person running the service: the first Reader, and the only one
// until registration exists. The seed makes sure exactly one readers row
// matches their Discord ID, carrying the SHA-256 of their epoch-0 userscript
// credential (derived by internal/token, not the retired global token).
// Owner is the person running the service: the first Reader, seeded at startup
// so a fresh deployment has a library before anyone logs in. The seed makes
// sure exactly one readers row matches their Discord ID, carrying the SHA-256
// of their epoch-0 userscript credential (derived by internal/token). Every
// other Reader is created by their own first login (EnsureReader).
type Owner struct {
DiscordID string
// TokenHash is the SHA-256 of the epoch-0 credential; the array shape
@@ -183,15 +184,14 @@ type Owner struct {
// Store is the Postgres-backed bookmark store.
type Store struct {
db *sql.DB
// ownerID is the seeded owner Reader (issue #22). Authentication is still
// the single global token, so every request acts as this Reader; the store
// methods take the id explicitly so the scoping survives per-Reader auth.
// ownerID is the seeded owner Reader (issue #22) — the only Reader with
// administrative reach (revoking another Reader's sessions). Every store
// method takes a reader id explicitly, so ownership is never implicit.
ownerID int64
}
// OwnerID returns the seeded owner Reader's id — the Reader the retired
// global token resolves to during the grace window, and the only Reader while
// registration is closed.
// OwnerID returns the seeded owner Reader's id: the administrator, and the
// Reader every pre-registration bookmark belongs to.
func (s *Store) OwnerID() int64 { return s.ownerID }
// ReaderIDForTokenHash resolves the Reader whose stored credential hash
@@ -252,6 +252,66 @@ func (s *Store) RotateToken(readerID, expectedEpoch int64, newHash [32]byte) err
return nil
}
// EnsureReader returns the Reader registered to discordID, creating the row on
// first sight. Registration is open to every guild member (issue #27), and the
// Discord identity is the only thing that decides which Reader a login is: one
// code path serves the first login and every later one, so a returning Reader
// can never end up with a second library.
//
// epochZeroHash is only used for a brand-new row. An existing row keeps its
// stored hash untouched, or a login would silently undo a rotation and revive
// the credential the Reader rotated away from.
func (s *Store) EnsureReader(discordID string, epochZeroHash [32]byte) (int64, error) {
var id int64
// DO UPDATE rather than DO NOTHING because only an updated row is
// returned by RETURNING; assigning the column to itself is the no-op that
// makes the existing id come back.
err := s.db.QueryRow(`
INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2)
ON CONFLICT (discord_id) DO UPDATE SET discord_id = readers.discord_id
RETURNING id`, discordID, epochZeroHash[:]).Scan(&id)
if err != nil {
return 0, fmt.Errorf("ensure reader: %w", err)
}
return id, nil
}
// ReaderSummary is one Reader as the owner's administration panel sees them:
// who they are and how many live sessions they hold. No credential material,
// hashed or otherwise, is exposed.
type ReaderSummary struct {
ID int64
DiscordID string
// Sessions counts unexpired session rows — what the owner revokes.
Sessions int
}
// Readers lists every Reader with their live session count, oldest first, so
// the owner row (always the oldest) heads the list.
func (s *Store) Readers() ([]ReaderSummary, error) {
rows, err := s.db.Query(`
SELECT r.id, r.discord_id,
count(sess.id) FILTER (WHERE sess.expires_at > now()) AS sessions
FROM readers r
LEFT JOIN sessions sess ON sess.reader_id = r.id
GROUP BY r.id, r.discord_id
ORDER BY r.id`)
if err != nil {
return nil, fmt.Errorf("query readers: %w", err)
}
defer rows.Close()
out := []ReaderSummary{}
for rows.Next() {
var r ReaderSummary
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Sessions); err != nil {
return nil, fmt.Errorf("scan reader: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// readersMigration is the version that creates the readers table. The owner
// seed runs between two migrate passes, so that the run-once migration which
// attaches existing bookmarks (0004) finds the owner row.
+157 -10
View File
@@ -16,7 +16,7 @@ import (
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
// testOwner is the owner every test store seeds. Tests that need a second
// reader insert one directly (see secondReader).
// reader register one (see secondReader).
var testOwner = Owner{DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash"))}
func newTestStore(t *testing.T) *Store {
@@ -29,17 +29,14 @@ func newTestStore(t *testing.T) *Store {
return store
}
// secondReader inserts an extra reader row and returns its id. The store API
// has no reader-creation path yet — the seed is the only one — so tests that
// need reader isolation insert directly.
// secondReader registers an extra reader through the same path a first login
// takes, and returns its id.
func secondReader(t *testing.T, s *Store) int64 {
t.Helper()
hash := sha256.Sum256([]byte("second-token-hash"))
var id int64
if err := s.db.QueryRow(
`INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) RETURNING id`,
"second-"+strconv.FormatInt(time.Now().UnixNano(), 10), hash[:]).Scan(&id); err != nil {
t.Fatalf("seed second reader: %v", err)
discordID := "second-" + strconv.FormatInt(time.Now().UnixNano(), 10)
id, err := s.EnsureReader(discordID, sha256.Sum256([]byte("token-"+discordID)))
if err != nil {
t.Fatalf("register second reader: %v", err)
}
return id
}
@@ -1052,3 +1049,153 @@ func TestDeleteReaderCascadesToBookmarks(t *testing.T) {
t.Fatalf("series = %+v, want it kept after its only reader was deleted", sr)
}
}
// Registration is one code path: the first sight of a Discord identity creates
// the Reader, every later one returns the same row. The epoch-0 hash argument
// is for creation only — a returning Reader who has rotated must not have that
// rotation undone by logging in again.
func TestEnsureReaderCreatesOnceAndNeverClobbersARotation(t *testing.T) {
s := newTestStore(t)
first, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
if err != nil {
t.Fatalf("EnsureReader: %v", err)
}
if first == s.OwnerID() {
t.Fatal("a new Discord identity resolved to the owner Reader")
}
if id, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil || !ok || id != first {
t.Fatalf("new Reader's credential resolved to (%d, %v, %v), want (%d, true, nil)", id, ok, err, first)
}
rotated := sha256.Sum256([]byte("cred-epoch-1"))
if err := s.RotateToken(first, 0, rotated); err != nil {
t.Fatalf("RotateToken: %v", err)
}
again, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
if err != nil {
t.Fatalf("second EnsureReader: %v", err)
}
if again != first {
t.Fatalf("second login returned Reader %d, want the existing %d", again, first)
}
if _, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil {
t.Fatalf("stale lookup: %v", err)
} else if ok {
t.Fatal("logging in again revived the pre-rotation credential")
}
if id, ok, err := s.ReaderIDForTokenHash(rotated); err != nil || !ok || id != first {
t.Fatalf("rotated credential resolved to (%d, %v, %v), want the same Reader", id, ok, err)
}
// Signing in as the owner's own Discord identity reuses the seeded row
// rather than minting a duplicate library.
if id, err := s.EnsureReader(testOwner.DiscordID, sha256.Sum256([]byte("ignored"))); err != nil {
t.Fatalf("EnsureReader(owner): %v", err)
} else if id != s.OwnerID() {
t.Fatalf("owner login returned Reader %d, want the seeded owner %d", id, s.OwnerID())
}
}
// The owner's administration view: who exists and how many live sessions each
// holds. Revocation drops all of one Reader's sessions and nobody else's.
func TestReadersAndSessionRevocation(t *testing.T) {
s := newTestStore(t)
other := secondReader(t, s)
for _, id := range []string{"own-1", "own-2"} {
if _, err := s.CreateSession(id, s.OwnerID(), time.Hour); err != nil {
t.Fatalf("CreateSession(%s): %v", id, err)
}
}
if _, err := s.CreateSession("other-1", other, time.Hour); err != nil {
t.Fatalf("CreateSession(other): %v", err)
}
// An expired row must not be counted as a session the owner can revoke.
if _, err := s.CreateSession("other-dead", other, -time.Minute); err != nil {
t.Fatalf("CreateSession(expired): %v", err)
}
readers, err := s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
if len(readers) != 2 || readers[0].ID != s.OwnerID() || readers[1].ID != other {
t.Fatalf("readers = %+v, want the owner then the second Reader", readers)
}
if readers[0].DiscordID != testOwner.DiscordID {
t.Fatalf("owner discord id = %q, want %q", readers[0].DiscordID, testOwner.DiscordID)
}
if readers[0].Sessions != 2 || readers[1].Sessions != 1 {
t.Fatalf("session counts = %d, %d; want 2 and 1 live", readers[0].Sessions, readers[1].Sessions)
}
if err := s.DeleteReaderSessions(other); err != nil {
t.Fatalf("DeleteReaderSessions: %v", err)
}
if _, ok, err := s.GetSession("other-1", time.Now()); err != nil || ok {
t.Fatalf("revoked session still resolves: ok=%v err=%v", ok, err)
}
if _, ok, err := s.GetSession("own-1", time.Now()); err != nil || !ok {
t.Fatalf("owner's session was collateral: ok=%v err=%v", ok, err)
}
}
// Two Readers on one Series: one series row, two independent progresses. The
// second Reader starts at zero however far the first has read, and the shared
// row is still due exactly once.
func TestTwoReadersShareOneSeriesWithIndependentProgress(t *testing.T) {
s := newTestStore(t)
other := secondReader(t, s)
if _, err := s.Upsert(s.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
LastChapter: "Chapter 200", LastChapterNum: 200, UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed owner: %v", err)
}
theirs, err := s.Upsert(other, Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 2000,
})
if err != nil {
t.Fatalf("seed other: %v", err)
}
if theirs.LastChapterNum != 0 || theirs.LastChapter != "" {
t.Fatalf("second Reader's progress = %+v, want zero regardless of the first's 200", theirs)
}
// The shared facts are still shared: the series row it joined to is the
// one the first Reader created.
if theirs.Title != "Solo Leveling" {
t.Fatalf("second Reader's title = %q, want the shared series title", theirs.Title)
}
var series int
if err := s.db.QueryRow(`SELECT count(*) FROM series`).Scan(&series); err != nil {
t.Fatalf("count series: %v", err)
}
if series != 1 {
t.Fatalf("series rows = %d, want 1 shared row for two bookmarks", series)
}
due, err := s.DueForLatestCheck(time.Now().UnixMilli(), 10)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
if len(due) != 1 || due[0].Key() != "asura:solo" {
t.Fatalf("due = %+v, want the shared series exactly once per cycle", due)
}
// One Reader dropping their bookmark leaves the other's intact and the
// series still polled.
if err := s.Delete(other, "asura:solo"); err != nil {
t.Fatalf("Delete(other): %v", err)
}
if b, ok, err := s.Get(s.OwnerID(), "asura:solo"); err != nil || !ok || b.LastChapterNum != 200 {
t.Fatalf("owner's bookmark after the other's delete = %+v ok=%v err=%v, want it intact", b, ok, err)
}
due, err = s.DueForLatestCheck(time.Now().UnixMilli(), 10)
if err != nil {
t.Fatalf("DueForLatestCheck after delete: %v", err)
}
if len(due) != 1 || due[0].Key() != "asura:solo" {
t.Fatalf("due after one Reader left = %+v, want the series still polled", due)
}
}
+6 -18
View File
@@ -10,7 +10,6 @@ import (
"bookmarkmanager/backend/internal/httpmw"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
)
// tokenPlaceholder is what the bindmounted userscript carries where the
@@ -87,26 +86,15 @@ func Render(w http.ResponseWriter, r *http.Request, path, credential string) {
// the route exists. The same credential authenticates the API bearer header,
// so the two are one secret with one blast radius.
//
// The credential substituted is the resolved Reader's derived one, not the
// raw path segment: while the retired global token is still accepted during
// the grace window (httpmw.ResolveReader), an already-installed script
// polling its legacy URL is served a copy carrying the Reader's own
// credential, so the next update poll migrates the device onto its per-Reader
// path — the window empties itself instead of ending in a silent 401 for
// every device that never visited the web UI.
func Handler(s *store.Store, tokenKey []byte, legacy string, graceUntil time.Time, path string) http.HandlerFunc {
// The path segment is the credential itself, so once it resolves it is also
// exactly what the served copy must carry — no re-derivation needed.
func Handler(s *store.Store, path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
readerID, ok := httpmw.ResolveReader(s, legacy, graceUntil, r.PathValue("token"))
if !ok {
cred := r.PathValue("token")
if _, ok := httpmw.ResolveReader(s, cred); !ok {
http.NotFound(w, r)
return
}
discordID, epoch, err := s.ReaderTokenInfo(readerID)
if err != nil {
log.Printf("userscript: reader %d token info: %v", readerID, err)
http.NotFound(w, r)
return
}
Render(w, r, path, token.Token(tokenKey, discordID, epoch))
Render(w, r, path, cred)
}
}
+17 -12
View File
@@ -16,6 +16,7 @@ import (
"time"
"bookmarkmanager/backend/internal/session"
"bookmarkmanager/backend/internal/token"
)
const (
@@ -50,9 +51,6 @@ type DiscordConfig struct {
// RedirectURI is the full public URL of the callback — Discord requires
// the exact string, so it is configured, never derived from headers.
RedirectURI string
// OwnerDiscordID is the only Discord identity allowed to sign in until
// registration exists (issue #23).
OwnerDiscordID string
}
// oauthStates stores one-time sign-in states. A state is generated at
@@ -166,14 +164,6 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) {
"Discord sign-in is unavailable right now. Try again in a moment.")
return
}
if userID != h.discord.OwnerDiscordID {
h.limiter.Fail(ip, time.Now())
h.renderLogin(w, http.StatusForbidden,
"This Discord account is not the library owner.")
return
}
member, isMember, err := h.discordMember(r.Context(), tok.AccessToken)
if err != nil {
h.limiter.Fail(ip, time.Now())
@@ -185,6 +175,10 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) {
// The refusal is the same for a non-member and a member without the
// required role, and it names neither the guild nor its id: an outsider
// cannot tell whether the guild exists, let alone which one gates.
//
// It also returns before EnsureReader, so a refused sign-in leaves no
// Reader row behind — the gate is the only thing standing between guild
// membership and a library.
if !isMember || (h.discord.RequiredRole != "" && !slices.Contains(member.Roles, h.discord.RequiredRole)) {
h.limiter.Fail(ip, time.Now())
h.renderLogin(w, http.StatusForbidden,
@@ -192,8 +186,19 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) {
return
}
// Registration is the login (issue #27): first sight of a guild member
// creates their Reader, every later sight returns the same one. Their
// userscript credential is derived at epoch 0 the way the owner's is, so
// the install links work before they have read anything.
readerID, err := h.store.EnsureReader(userID, token.Hash(token.Token(h.tokenKey, userID, 0)))
if err != nil {
log.Printf("register reader: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.limiter.Reset(ip)
sess, err := h.store.CreateSession(session.NewID(), h.readerID, session.SessionTTL)
sess, err := h.store.CreateSession(session.NewID(), readerID, session.SessionTTL)
if err != nil {
log.Printf("create session: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
+27
View File
@@ -290,6 +290,33 @@ button { cursor: pointer; }
letter-spacing: .04em;
}
/* ---- reader roster (owner only): same hairline panel, one row per Reader ---- */
.readerlist { margin: 0; padding: 0; list-style: none; }
.readerlist li {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 16px;
min-height: 44px;
border-top: 1px solid var(--rule);
}
.readerlist form { margin: 0 0 0 auto; }
.reader-id {
font: 500 13px/1.4 var(--font-mono);
letter-spacing: .04em;
color: var(--paper);
}
.reader-sessions {
font: 500 10px/1 var(--font-mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--mute);
}
/* Revocation cuts someone off, so it wears --danger. Ember stays reserved for
the new-chapter signal. */
.ghost.danger { color: var(--danger); }
.ghost.danger:hover { color: var(--danger); border-bottom-color: var(--danger); }
.chrome { display: flex; flex-direction: column; }
.searchbar {
+2
View File
@@ -76,6 +76,8 @@
{{template "setup" .}}
{{if .Owner}}{{template "readers" .}}{{end}}
{{template "keyrow" .}}
{{template "recent" .}}
+11
View File
@@ -16,6 +16,17 @@
<div class="empty"><strong>Nothing archived.</strong><p>Shelve a series to park it here — it keeps getting checked for new chapters.</p></div>
{{else if eq .Tab "finished"}}
<div class="empty"><strong>Nothing finished yet.</strong><p>Mark a series finished and it moves out of your reading list.</p></div>
{{else if .EmptyLibrary}}
{{/* Nothing in either library, so the links are the only thing this page can
usefully say. Both scripts: the two libraries are separate installs. */}}
<div class="empty">
<strong>Nothing here yet.</strong>
<p>Install the userscripts, then open a series and read a chapter — bookmarks arrive on their own.</p>
<p class="setup-links">
<a class="ghost" href="/install/manga-bookmark.user.js">Install Manga script</a>
<a class="ghost" href="/install/novel-bookmark.user.js">Install Novels script</a>
</p>
</div>
{{else}}
<div class="empty"><strong>Nothing here yet.</strong><p>Bookmarks appear once the userscript records a chapter.</p></div>
{{end}}
@@ -0,0 +1,29 @@
{{/* The owner's Reader roster. Rendered only for the owner (listView.Owner),
and re-rendered whole as the response to a revocation so the session
counts cannot describe the state before the tap. Revocation is
confirm-gated: it signs someone out of every device at once. */}}
{{define "readers"}}
<details class="setup" id="readers">
<summary>Readers</summary>
<p class="setup-copy">Everyone who has signed in through Discord. Revoking
signs a Reader out of every device; their library and bookmarks are
untouched, and they can sign in again.</p>
<ul class="readerlist">
{{range .Readers}}
<li>
<span class="reader-id">{{.DiscordID}}</span>
<span class="reader-sessions">{{.Sessions}} session{{if ne .Sessions 1}}s{{end}}</span>
{{/* The owner's own row never offers Revoke: it is the one row where the
button would sign the tapping browser out, and the endpoint refuses
it anyway. Logout is the deliberate way to do that. */}}
{{if and .Sessions (ne .ID $.OwnerID)}}
<form hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
hx-confirm="Revoking signs this Reader out on every device immediately. Revoke?">
<button type="submit" class="ghost danger">Revoke sessions</button>
</form>
{{end}}
</li>
{{end}}
</ul>
</details>
{{end}}
+68 -7
View File
@@ -34,10 +34,6 @@ const RecentCount = 5
// representations (HTML versus JSON) to different clients under different auth.
type Handler struct {
store *store.Store
// readerID is the owner Reader's id, the only Reader that can exist
// while registration is closed (issue #23). Every session row points at
// it, so it is also the Reader the UI acts as.
readerID int64
// tokenKey derives Readers' userscript credentials (internal/token): the
// install endpoints render the scripts with the credential inside, which
// is the one place the UI needs the secret.
@@ -76,6 +72,19 @@ type listView struct {
// Rotated marks the setup panel as having just rotated the credential:
// it swaps the reinstall warning in over the button row.
Rotated bool
// EmptyLibrary means this Reader holds no bookmarks in either library, so
// the empty state can offer the installs instead of reporting on a filter.
// It is not "newly registered": a Reader who deletes their last bookmark is
// in the same position and needs the same links.
EmptyLibrary bool
// Owner marks the acting Reader as the deployment's owner, which unlocks
// the Readers panel. Nothing else in the UI differs.
Owner bool
// Readers is the owner's roster, populated only for the owner's own page
// render and the revocation fragment. OwnerID travels with it so the roster
// can tell the owner's own row apart from the Readers they may revoke.
Readers []store.ReaderSummary
OwnerID int64
}
// PageURL and ListURL are the two link shapes every tab needs. Building them
@@ -102,14 +111,13 @@ type loginView struct {
// New parses every template up front so a broken one kills the process at
// startup rather than the first request that touches it.
func New(s *store.Store, readerID int64, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) {
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
}
return &Handler{
store: s,
readerID: readerID,
tokenKey: tokenKey,
mangaUserscriptPath: mangaPath,
novelUserscriptPath: novelPath,
@@ -141,6 +149,9 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /install/manga-bookmark.user.js", h.requireSession(h.installUserscript("manga-bookmark.user.js")))
mux.HandleFunc("GET /install/novel-bookmark.user.js", h.requireSession(h.installUserscript("novel-bookmark.user.js")))
mux.HandleFunc("POST /rotate-token", h.requireSession(h.rotateToken))
// Owner-only: the one place the UI crosses the Reader boundary.
mux.HandleFunc("POST /readers/{id}/revoke", h.requireSession(h.revokeReaderSessions))
}
// staticHandler serves the embedded assets. An hour, not longer: assets are
@@ -229,6 +240,14 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if readerID == h.store.OwnerID() {
view.Owner, view.OwnerID = true, readerID
if view.Readers, err = h.store.Readers(); err != nil {
log.Printf("index readers: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
h.render(w, http.StatusOK, "app", view)
}
@@ -276,6 +295,10 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro
if err != nil {
return listView{}, err
}
// Taken before the filter narrows the slice: a Reader with novels but no
// manga has a working install already, and does not need to be told to go
// and get one.
emptyLibrary := len(all) == 0
// Narrow to one library first: reading, withNew and recent all derive from
// this slice, so doing it later would let the other library's rows into the
// strip and the Updated badge.
@@ -319,7 +342,8 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro
recent = recent[:RecentCount]
}
}
return listView{Lib: lib, Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
return listView{Lib: lib, Tab: tab, Recent: recent, Items: items,
NewCount: len(withNew), EmptyLibrary: emptyLibrary}, nil
}
func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) {
@@ -594,3 +618,40 @@ func (h *Handler) rotateToken(w http.ResponseWriter, r *http.Request) {
view := listView{Lib: store.KindManga, Rotated: true}
h.render(w, http.StatusOK, "setup", view)
}
// revokeReaderSessions logs one Reader out of every browser they are signed
// in on. Owner-only: it reaches across the Reader boundary every other handler
// respects, so the guard is a comparison against the seeded owner rather than
// a role a Reader could acquire. A non-owner gets 404 — the panel does not
// exist for them, so neither should the endpoint.
func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
if readerOf(r) != h.store.OwnerID() {
http.NotFound(w, r)
return
}
target, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "bad reader id", http.StatusBadRequest)
return
}
// The owner is not one of the Readers this endpoint reaches: revoking
// themselves would sign out the browser making the request, which is what
// logout is for. The roster hides the button; this refuses the hand-rolled
// POST behind it.
if target == h.store.OwnerID() {
http.NotFound(w, r)
return
}
if err := h.store.DeleteReaderSessions(target); err != nil {
log.Printf("revoke sessions: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
readers, err := h.store.Readers()
if err != nil {
log.Printf("revoke sessions: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.render(w, http.StatusOK, "readers", listView{Owner: true, Readers: readers, OwnerID: h.store.OwnerID()})
}
+14 -50
View File
@@ -23,26 +23,18 @@ import (
// Config holds all runtime settings, sourced from environment variables.
type Config struct {
// Token is the retired global API token, kept only for the cutover grace
// window: while GraceUntil has not passed, it resolves to the owner
// Reader so already-installed scripts keep working. Unset after the
// window closes.
Token string
// TokenKey derives every Reader's userscript credential (internal/token).
// Required: without it no install URL can ever be built.
TokenKey string
// GraceUntil is the moment the retired global token stops resolving to
// the owner Reader. Zero means the token is already dead. Enforced in
// code on every request, not by a runbook note.
GraceUntil time.Time
TokenKey string
AllowedOrigins []string
// DatabaseURL is the Postgres connection URL; required, no default,
// because a wrong guess would silently start on an empty database.
DatabaseURL string
Port string
// OwnerDiscordID identifies the seeded owner Reader (issue #22). Required:
// bookmarks are scoped to a Reader, and without an owner there is none.
// It is also the only Discord identity allowed to sign in (issue #23).
// bookmarks are scoped to a Reader, and a fresh deployment needs one
// before anybody logs in. The owner is also the only Reader who can revoke
// another Reader's sessions.
OwnerDiscordID string
// Discord is the OAuth application the browser UI signs in with.
Discord web.DiscordConfig
@@ -158,29 +150,9 @@ func loadLatestPoll() LatestPoll {
return p
}
// parseGraceUntil reads the retired-token deadline. Both a bare date and a
// full RFC3339 timestamp are accepted; an unparseable value is a
// configuration bug, not a gracefully-degraded feature — the whole point is
// that the window's end is enforced, so fail loud.
func parseGraceUntil(raw string) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(layout, raw); err == nil {
return t
}
}
log.Fatalf("config: API_TOKEN_GRACE_UNTIL=%q is not a date (YYYY-MM-DD) or RFC3339 timestamp", raw)
return time.Time{}
}
func loadConfig() Config {
c := Config{
Token: os.Getenv("API_TOKEN"),
TokenKey: os.Getenv("TOKEN_KEY"),
GraceUntil: parseGraceUntil(os.Getenv("API_TOKEN_GRACE_UNTIL")),
DatabaseURL: os.Getenv("DATABASE_URL"),
Port: envOr("PORT", "8080"),
OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"),
@@ -189,13 +161,12 @@ func loadConfig() Config {
LatestPoll: loadLatestPoll(),
}
c.Discord = web.DiscordConfig{
ClientID: os.Getenv("DISCORD_CLIENT_ID"),
ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"),
GuildID: os.Getenv("DISCORD_GUILD_ID"),
RequiredRole: os.Getenv("DISCORD_REQUIRED_ROLE"),
APIBase: envOr("DISCORD_API_BASE", "https://discord.com/api/v10"),
RedirectURI: os.Getenv("DISCORD_REDIRECT_URI"),
OwnerDiscordID: c.OwnerDiscordID,
ClientID: os.Getenv("DISCORD_CLIENT_ID"),
ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"),
GuildID: os.Getenv("DISCORD_GUILD_ID"),
RequiredRole: os.Getenv("DISCORD_REQUIRED_ROLE"),
APIBase: envOr("DISCORD_API_BASE", "https://discord.com/api/v10"),
RedirectURI: os.Getenv("DISCORD_REDIRECT_URI"),
}
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
if o = strings.TrimSpace(o); o != "" {
@@ -218,9 +189,9 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
// instead, and the script is rendered with the resolved Reader's
// credential substituted in.
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js",
userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.UserscriptPath))
userscript.Handler(s, cfg.UserscriptPath))
mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js",
userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.NovelUserscriptPath))
userscript.Handler(s, cfg.NovelUserscriptPath))
h := &api.Handler{Store: s}
protected := http.NewServeMux()
@@ -228,13 +199,13 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete)
auth := httpmw.Auth(s, cfg.Token, cfg.GraceUntil, protected)
auth := httpmw.Auth(s, protected)
mux.Handle("/bookmarks", auth)
mux.Handle("/bookmarks/", auth)
// The browser UI is always registered; signing in is Discord OAuth, so
// there is no password to forget and no gate to leave unset.
wh, err := web.New(s, s.OwnerID(), cfg.Discord, []byte(cfg.TokenKey),
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
cfg.UserscriptPath, cfg.NovelUserscriptPath)
if err != nil {
log.Fatalf("web handler: %v", err)
@@ -282,13 +253,6 @@ func main() {
log.Fatalf("%s is required", key)
}
}
if cfg.Token == "" && !cfg.GraceUntil.IsZero() {
log.Fatal("API_TOKEN_GRACE_UNTIL is set but API_TOKEN is not")
}
if cfg.Token != "" && cfg.GraceUntil.IsZero() {
log.Printf("API_TOKEN is set without API_TOKEN_GRACE_UNTIL: the retired token is dead on arrival")
}
// The owner's userscript credential is derived from TOKEN_KEY at epoch 0
// (internal/token); the readers row carries its SHA-256, not the
// credential itself.
+1 -1
View File
@@ -202,7 +202,7 @@ func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
}
func TestGzipCompressesTextNotFonts(t *testing.T) {
srv, _ := newWebTestServer(t, webConfig())
srv, _ := newWebTestServer(t, testConfig())
cases := []struct {
path string
+78 -84
View File
@@ -1,7 +1,6 @@
package main
import (
"database/sql"
"encoding/json"
"io"
"net/http"
@@ -10,31 +9,19 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
_ "github.com/jackc/pgx/v5/stdlib"
)
// insertReader creates an extra reader row (registration is closed, so the
// store has no path for this — tests reach past it) and returns its id. The
// credential is derived the same way the owner's is, so it authenticates
// through the real router.
func insertReader(t *testing.T, dbURL, discordID string) int64 {
// registerReader creates an extra Reader the way a first login does and
// returns its id. The credential is derived the same way the owner's is, so it
// authenticates through the real router.
func registerReader(t *testing.T, s *store.Store, discordID string) int64 {
t.Helper()
db, err := sql.Open("pgx", dbURL)
id, err := s.EnsureReader(discordID, token.Hash(readerCredential(discordID)))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
hash := token.Hash(token.Token([]byte(testTokenKey), discordID, 0))
var id int64
if err := db.QueryRow(
`INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) RETURNING id`,
discordID, hash[:]).Scan(&id); err != nil {
t.Fatalf("insert reader: %v", err)
t.Fatalf("register reader %q: %v", discordID, err)
}
return id
}
@@ -59,34 +46,29 @@ func withBody(req *http.Request, body string) *http.Request {
return req
}
// The retired global token resolves to the owner Reader only while the grace
// deadline is in the future — testConfig sets it, so the acceptance path is
// the existing auth() tests; this pins the other side of the window.
func TestLegacyTokenDeadAfterGrace(t *testing.T) {
s := newTestStore(t)
cfg := testConfig()
cfg.GraceUntil = time.Now().Add(-time.Hour)
srv := newRouter(s, cfg)
// A refused credential is refused however plausible it looks: only a hash the
// readers table holds authenticates anything.
func TestUnknownCredentialRejected(t *testing.T) {
srv := newRouter(newTestStore(t), testConfig())
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", testToken))
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered")))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("legacy token after grace: status = %d, want 401", rr.Code)
t.Fatalf("unregistered Reader's credential: status = %d, want 401", rr.Code)
}
// The owner's own derived credential is unaffected by the window closing.
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential()))
if rr.Code != http.StatusOK {
t.Fatalf("derived token after grace: status = %d, want 200", rr.Code)
t.Fatalf("owner's derived credential: status = %d, want 200", rr.Code)
}
}
// A Reader's credential authenticates exactly that Reader: rows written under
// one credential are invisible to the other, on the same key.
func TestPerReaderIsolation(t *testing.T) {
s, dbURL := newTestStoreURL(t)
insertReader(t, dbURL, "other-reader")
s := newTestStore(t)
registerReader(t, s, "other-reader")
srv := newRouter(s, testConfig())
ownerKey := "asura:solo"
@@ -137,24 +119,73 @@ func TestPerReaderIsolation(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &owners); err != nil {
t.Fatalf("decode: %v", err)
}
if len(owners) != 1 || owners[0].Title != "Solo Leveling" {
t.Fatalf("owner list = %+v, want their own row", owners)
if len(owners) != 1 || owners[0].Title != "Solo Leveling" || owners[0].LastChapterNum != 0 {
t.Fatalf("owner list = %+v, want their own row at their own progress", owners)
}
// One Reader's credential cannot delete the other's row.
req = credRequest(http.MethodDelete, "/bookmarks/"+ownerKey, readerCredential("other-reader"))
// The mirror: the owner's write does not move the other Reader's progress
// either. Without it, isolation is only asserted in one direction.
req = credRequest(http.MethodPut, "/bookmarks/"+ownerKey, ownerCredential())
req.Header.Set("Content-Type", "application/json")
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("other reader delete: status = %d, want 204", rr.Code)
srv.ServeHTTP(rr, withBody(req, `{"key":"asura:solo","site":"asura","series_id":"solo","title":"Solo Leveling","last_chapter_num":9}`))
if rr.Code != http.StatusOK {
t.Fatalf("owner put: status = %d, want 200", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential()))
if err := json.Unmarshal(rr.Body.Bytes(), &owners); err != nil {
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("other-reader")))
var theirs3 []store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &theirs3); err != nil {
t.Fatalf("decode: %v", err)
}
if len(owners) != 1 {
t.Fatalf("owner's row was deletable by another Reader: list = %+v", owners)
if len(theirs3) != 1 || theirs3[0].LastChapterNum != 3 {
t.Fatalf("other reader list = %+v, want progress 3 after the owner's write", theirs3)
}
// DELETE is scoped to its caller too, asserted in both directions: each
// Reader's delete on the shared key takes only their own row.
list := func(cred string) []store.Bookmark {
t.Helper()
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", cred))
var got []store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
return got
}
del := func(cred string) {
t.Helper()
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, credRequest(http.MethodDelete, "/bookmarks/"+ownerKey, cred))
if rr.Code != http.StatusNoContent {
t.Fatalf("delete: status = %d, want 204", rr.Code)
}
}
del(readerCredential("other-reader"))
if got := list(ownerCredential()); len(got) != 1 {
t.Fatalf("owner's row was deletable by the other Reader: %+v", got)
}
if got := list(readerCredential("other-reader")); len(got) != 0 {
t.Fatalf("other Reader's own delete left %+v behind", got)
}
// The mirror: the other Reader takes the key again, the owner deletes
// theirs, and the other's row is untouched.
req = credRequest(http.MethodPut, "/bookmarks/"+ownerKey, readerCredential("other-reader"))
req.Header.Set("Content-Type", "application/json")
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, withBody(req, body))
if rr.Code != http.StatusOK {
t.Fatalf("other reader re-put: status = %d, want 200", rr.Code)
}
del(ownerCredential())
if got := list(readerCredential("other-reader")); len(got) != 1 {
t.Fatalf("other Reader's row was deletable by the owner: %+v", got)
}
if got := list(ownerCredential()); len(got) != 0 {
t.Fatalf("owner's own delete left %+v behind", got)
}
}
@@ -162,7 +193,7 @@ func TestPerReaderIsolation(t *testing.T) {
// with the Reader's credential inside: the credential never appears in the
// address bar, the page markup, or any Location header.
func TestInstallServesScriptWithCredential(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
dir := t.TempDir()
path := filepath.Join(dir, "manga-bookmark.user.js")
novelPath := filepath.Join(dir, "novel-bookmark.user.js")
@@ -225,43 +256,6 @@ func TestInstallServesScriptWithCredential(t *testing.T) {
}
}
// The retired global token also keeps the script download path working during
// the grace window — that is how already-installed scripts auto-update across
// the cutover — and dies with it. The copy served on the legacy path embeds
// the Reader's derived credential, so the next update poll migrates the
// device onto its per-Reader path: the window empties itself.
func TestLegacyTokenUserscriptPathDuringGrace(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)
}
s := newTestStore(t)
cfg := testConfig()
cfg.UserscriptPath = path
// Within the window the legacy URL serves the script, but with the
// owner's derived credential substituted — not the legacy one.
rr := httptest.NewRecorder()
newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
"/u/"+testToken+"/manga-bookmark.user.js", nil))
if rr.Code != http.StatusOK {
t.Fatalf("legacy path during grace: status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+ownerCredential()+`"`) {
t.Fatalf("legacy-path script does not carry the derived credential:\n%s", got)
}
// After the deadline the same URL is a 404 like any unknown credential.
cfg.GraceUntil = time.Now().Add(-time.Hour)
rr = httptest.NewRecorder()
newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
"/u/"+testToken+"/manga-bookmark.user.js", nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("legacy path after grace: status = %d, want 404", rr.Code)
}
}
// Rotation through the web UI invalidates the old credential immediately,
// mints one that authenticates the API and the script path, and warns that
// every device must reinstall.
@@ -271,7 +265,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) {
if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
t.Fatalf("write script: %v", err)
}
cfg := webConfig()
cfg := testConfig()
cfg.UserscriptPath = path
srv := newRouter(s, cfg)
@@ -338,7 +332,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) {
// The app page offers the install links; the credential never appears in its
// markup.
func TestIndexShowsSetupPanelWithoutCredential(t *testing.T) {
srv, st := newWebTestServer(t, webConfig())
srv, st := newWebTestServer(t, testConfig())
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
+262 -78
View File
@@ -1,13 +1,14 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
@@ -17,20 +18,13 @@ import (
"bookmarkmanager/backend/internal/session"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/web"
_ "github.com/jackc/pgx/v5/stdlib"
)
// 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"
// 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.Discord.OwnerDiscordID = testOwnerID
return cfg
}
// newWebTestServer returns the full router plus the store behind it, so tests
// can seed rows and assert on what the handlers wrote back.
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
@@ -137,12 +131,11 @@ func newDiscordStub(t *testing.T) (*discordStub, *httptest.Server) {
// 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",
OwnerDiscordID: testOwnerID,
ClientID: "client-1",
ClientSecret: "client-secret-1",
GuildID: "guild-1",
APIBase: stubURL,
RedirectURI: "https://bm.example.com/auth/discord/callback",
}
}
@@ -151,7 +144,7 @@ func discordConfig(stubURL string) web.DiscordConfig {
func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) {
t.Helper()
stub, srv := newDiscordStub(t)
cfg := webConfig()
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
router, st := newWebTestServer(t, cfg)
return router, st, stub
@@ -190,24 +183,40 @@ func completeSignIn(t *testing.T, srv http.Handler, state string) *httptest.Resp
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 {
// storeReaders is the roster, ordered oldest first — the owner heads it.
func storeReaders(t *testing.T, st *store.Store) []store.ReaderSummary {
t.Helper()
db, err := sql.Open("pgx", url)
readers, err := st.Readers()
if err != nil {
t.Fatalf("open: %v", err)
t.Fatalf("Readers: %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 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 n
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, webConfig())
srv, _ := newWebTestServer(t, testConfig())
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
@@ -220,7 +229,7 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) {
}
func TestIndexWithSessionShowsList(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
if _, err := st.Upsert(st.OwnerID(), store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -397,10 +406,10 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
stub.member = tc.member
stub.memberStatus = tc.memberStatus
stub.roles = tc.roles
cfg := webConfig()
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
cfg.Discord.RequiredRole = tc.require
st, dbURL := newTestStoreURL(t)
st := newTestStore(t)
router := newRouter(st, cfg)
rr := completeSignIn(t, router, startSignIn(t, router))
@@ -417,7 +426,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
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 {
if n := len(storeReaders(t, st)); n != 1 {
t.Fatalf("readers = %d after a refusal, want 1", n)
}
})
@@ -428,7 +437,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
func TestDiscordLoginRequiresRolePositive(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.roles = []string{"role-1"}
cfg := webConfig()
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
cfg.Discord.RequiredRole = "role-1"
router, st := newWebTestServer(t, cfg)
@@ -446,32 +455,207 @@ func TestDiscordLoginRequiresRolePositive(t *testing.T) {
}
}
func TestDiscordLoginRefusesNonOwner(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.ownerID = "someone-elses-snowflake"
cfg := webConfig()
cfg.Discord = discordConfig(srv.URL)
// 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)
rr := completeSignIn(t, router, startSignIn(t, router))
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rr.Code)
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 !strings.Contains(rr.Body.String(), "not the library owner") {
t.Fatalf("refusal body = %q, want the owner-only explanation", rr.Body.String())
if n := len(storeReaders(t, st)); n != 1 {
t.Fatalf("readers after the owner's login = %d, want 1 (the seed was duplicated)", n)
}
if len(rr.Result().Cookies()) != 0 {
t.Fatal("a refused sign-in set a cookie")
}
// 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)
}
if sess, ok, _ := st.GetSession("anything", time.Now()); ok && sess.ID != "" {
t.Fatal("a refused sign-in created a session")
_, 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")
}
}
// The owner's own page carries the roster; nobody else's does.
func TestOwnerSeesReadersPanel(t *testing.T) {
router, st, _ := oauthWebTestServer(t)
signInCookie(t, router)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, `id="readers"`) {
t.Fatal("the owner's page lacks the Readers panel")
}
if !strings.Contains(body, testOwnerID) {
t.Fatalf("the roster does not list the registered Reader:\n%s", body)
}
if !strings.Contains(body, "Revoke sessions") {
t.Fatal("the roster offers no revocation control for a signed-in Reader")
}
// Exactly one revocable row: the other Reader's. The owner's own row carries
// the same session count and no button.
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)
}
}
func TestDiscordLoginTokenEndpointDown(t *testing.T) {
stub, srv := newDiscordStub(t)
stub.tokenStatus = http.StatusInternalServerError
cfg := webConfig()
cfg := testConfig()
cfg.Discord = discordConfig(srv.URL)
router, _ := newWebTestServer(t, cfg)
@@ -514,7 +698,7 @@ func TestCallbackRateLimited(t *testing.T) {
}
func TestLogoutDeletesSession(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
cookie := sessionCookie(t, st)
@@ -544,7 +728,7 @@ func TestLogoutDeletesSession(t *testing.T) {
}
func TestExpiredSessionRejected(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
sess, err := st.CreateSession(session.NewID(), st.OwnerID(), -time.Minute)
if err != nil {
@@ -570,7 +754,7 @@ func TestExpiredSessionRejected(t *testing.T) {
}
func TestBookmarksAPIStillBearerOnly(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
// A session cookie must not grant access to the userscript's JSON API.
@@ -591,7 +775,7 @@ func TestBookmarksAPIStillBearerOnly(t *testing.T) {
}
func TestStaticAssetsServed(t *testing.T) {
srv, _ := newWebTestServer(t, webConfig())
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))
@@ -628,7 +812,7 @@ func uiRequest(t *testing.T, st *store.Store, method, path string, form url.Valu
}
func TestUIRoutesRequireSession(t *testing.T) {
srv, _ := newWebTestServer(t, webConfig())
srv, _ := newWebTestServer(t, testConfig())
cases := []struct{ method, path string }{
{http.MethodGet, "/ui/list"},
{http.MethodPost, "/ui/bookmarks/asura:solo/favorite"},
@@ -647,7 +831,7 @@ func TestUIRoutesRequireSession(t *testing.T) {
}
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -696,7 +880,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
// 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 := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -722,7 +906,7 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
}
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -757,7 +941,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
}
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -797,7 +981,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
}
func TestChapterOverrideRejectsBadInput(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -821,7 +1005,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
}
func TestMutationsOnMissingKey(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
cases := []struct {
name string
@@ -842,7 +1026,7 @@ func TestMutationsOnMissingKey(t *testing.T) {
}
func TestUIDeleteRemovesRow(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -869,7 +1053,7 @@ func TestUIDeleteRemovesRow(t *testing.T) {
}
func TestUIListFavouritesTab(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -895,7 +1079,7 @@ func TestUIListFavouritesTab(t *testing.T) {
}
func TestUIListNewTab(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
@@ -949,7 +1133,7 @@ func seedStatusRows(t *testing.T, st *store.Store) {
}
func TestTabsShowOnlyTheirBucket(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1006,7 +1190,7 @@ func stripOf(t *testing.T, srv http.Handler, st *store.Store, tab string) string
// 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 := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading
@@ -1057,7 +1241,7 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
// The strip never grows past web.RecentCount, however many series are waiting.
func TestRecentStripCapped(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
for i := 0; i <= web.RecentCount; i++ {
b := store.Bookmark{
@@ -1088,7 +1272,7 @@ func postStatus(t *testing.T, srv http.Handler, st *store.Store, key, status str
}
func TestUIStatusSetsBucket(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1107,7 +1291,7 @@ func TestUIStatusSetsBucket(t *testing.T) {
}
func TestUIStatusRejectsUnknownValue(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1121,7 +1305,7 @@ func TestUIStatusRejectsUnknownValue(t *testing.T) {
}
func TestUIStatusRequiresSession(t *testing.T) {
srv, st := newWebTestServer(t, webConfig())
srv, st := newWebTestServer(t, testConfig())
seedStatusRows(t, st)
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
@@ -1136,7 +1320,7 @@ func TestUIStatusRequiresSession(t *testing.T) {
}
func TestUIStatusDoesNotReorderList(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1152,7 +1336,7 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
}
func TestCardShowsStatusControls(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1190,7 +1374,7 @@ func TestCardShowsStatusControls(t *testing.T) {
}
func TestAppRendersNewTabs(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
@@ -1209,7 +1393,7 @@ func TestAppRendersNewTabs(t *testing.T) {
// 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 := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
@@ -1255,7 +1439,7 @@ func seedLibraries(t *testing.T, st *store.Store) {
}
func TestLibrariesAreDisjoint(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
@@ -1286,7 +1470,7 @@ func TestLibrariesAreDisjoint(t *testing.T) {
// A row written before the kind column existed has none. It is manga.
func TestKindlessRowShowsInMangaLibrary(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:legacy", Site: "asura", SeriesID: "legacy",
@@ -1301,7 +1485,7 @@ func TestKindlessRowShowsInMangaLibrary(t *testing.T) {
}
func TestNovelPageOmitsUpdatedTab(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
@@ -1332,7 +1516,7 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) {
}
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
cfg := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)
@@ -1350,7 +1534,7 @@ func TestMangaPageKeepsUpdatedTab(t *testing.T) {
// 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 := webConfig()
cfg := testConfig()
srv, st := newWebTestServer(t, cfg)
seedLibraries(t, st)