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:
+20
-12
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
|
||||
{{template "setup" .}}
|
||||
|
||||
{{if .Owner}}{{template "readers" .}}{{end}}
|
||||
|
||||
{{template "keyrow" .}}
|
||||
|
||||
{{template "recent" .}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user